Chapter 19 of 24 · Python for Beginners — Complete Free Tutorial
Decorators and Context Managers
In the Python for Beginners series you have already written functions and opened files with with. Chapter 19 of this 24-chapter course introduces the two tools that scale both habits: decorators bolt extra behaviour onto a function without editing its body, and context managers guarantee that setup and cleanup always run as a pair.
Functions are objects — decorators use that
A function is a first-class value: assign it, pass it, return it. A decorator is any callable that takes a function and hands back a replacement. The key is that loud(cheer) is just a normal call whose argument happens to be a function — and @loud is only sugar for writing that assignment. The inner wrapper is what actually runs: it calls the original, adds something, and returns the result. functools.wraps copies the original metadata onto the wrapper, so tools that read __name__ still see cheer:
import functools
def loud(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs).upper()
return wrapper
@loud
def cheer(name):
return f"Go {name}!"
print(cheer("Ada"))
print(cheer.__name__)
Expected output:
GO ADA!
cheer
Remove functools.wraps and the second line prints wrapper: debuggers, help() and test reports all start lying about which function they mean. An alias such as alias = greet and a decorator are the same idea — only the decorator gets to wrap the function on the way past.
Stacking decorators and giving them arguments
Decorators stack like layers: hello below is wrapped by bang first, then by tag("b"). A decorator that returns a decorator also gains arguments, which is why tag("b") needs parentheses — it is a function call whose result is the real decorator:
def tag(name):
def decorator(func):
def wrapper(*args, **kwargs):
return f"<{name}>{func(*args, **kwargs)}</{name}>"
return wrapper
return decorator
def bang(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs) + "!"
return wrapper
@tag("b")
@bang
def hello(name):
return f"Hi {name}"
print(hello("Ada"))
Expected output: <b>Hi Ada!</b>
Decorators do not have to be functions. A class works too: __init__ receives the decorated function and a __call__ method does the work, which is handy when the decorator must remember state between calls — a call counter is the classic example. Use functools.update_wrapper(self, func) in __init__ to keep the original name, and @ treats the class exactly like a function.
Context managers: cleanup you can trust
A context manager is an object with two special methods. __enter__ runs when the with block starts and its return value is bound to as; __exit__ runs when the block ends — even when an exception tears through it. That is why with open(...) closes files, why with lock: releases a mutex (the file version appeared in chapter 15) and why timing wrappers never leak:
class LockedFile:
def __init__(self, path):
self.path = path
def __enter__(self):
print(f"opening {self.path}")
self.file = open(self.path, "w", encoding="utf-8")
return self.file
def __exit__(self, exc_type, exc, tb):
self.file.close()
print(f"closed {self.path}")
with LockedFile("note.txt") as handle:
handle.write("written safely")
Expected output:
opening note.txt
closed note.txt
__exit__ receives the exception type, value and traceback — or three Nones when the block succeeded. Return nothing and exceptions keep travelling upward; return True and they are swallowed, which is almost never what you want.
Writing a whole class for a small scope is verbose, so contextlib.contextmanager turns a generator into a context manager instead: lines before yield are the setup, the yielded value goes to as, and lines after yield are the cleanup. Wrap the yield in try/finally so cleanup still runs when the body raises:
import time
from contextlib import contextmanager
@contextmanager
def slow_step(label):
print(f"start {label}")
start = time.perf_counter()
try:
yield
finally:
print(f"end {label} (slept 0.1s: {time.perf_counter() - start >= 0.1})")
with slow_step("report"):
time.sleep(0.1)
Expected output:
start report
end report (slept 0.1s: True)
Worked example: retrying a flaky call
Now combine everything: a decorator that takes an argument, preserves the wrapped name, catches the ConnectionError you met in chapter 12, and fails loudly when the attempts run out:
import functools
def retry(times):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except ConnectionError:
print(f"attempt {attempt} failed, retrying")
raise RuntimeError(f"{func.__name__} gave up")
return wrapper
return decorator
attempts = {"n": 0}
@retry(times=3)
def fetch():
attempts["n"] += 1
if attempts["n"] < 3:
raise ConnectionError("timeout")
return "payload"
print("result:", fetch())
Expected output:
attempt 1 failed, retrying
attempt 2 failed, retrying
result: payload
The same shape fits timing, caching, logging or permission checks. Reach for with when something is acquired and released, and for a decorator when a call needs extra behaviour — each still does exactly one job, even when they surround each other.
Common mistakes
- Forgetting
functools.wraps— every wrapper then reports__name__ == "wrapper", so debuggers and test output point at the wrong function. Decorate each inner wrapper. - A decorator that forgets to return — miss
return wrapper(orreturn decorator) and the decorated name silently becomesNone; the first call raisesTypeError: 'NoneType' object is not callable. - Skipping try/finally after yield — in a
@contextmanagergenerator the cleanup afteryieldnever runs if the body raises, so your “close” silently stops happening under error conditions. - Returning True from
__exit__— it suppresses exceptions and hides real failures. Only swallow an exception you have genuinely handled. - Using the resource after the block — the file is closed and the lock released the moment the
withblock ends; finish the work inside it or copy the data out first.
Practice checklist
- Write your own
louddecorator, deletefunctools.wraps, and watchcheer.__name__change. - Stack two decorators and predict the exact output of
hello("Ada")before running it. - Apply
@retry(times=3)to a function that always raises and confirm theRuntimeErrorappears after exactly three attempts. - Make the body of a
withblock raise an exception and prove your context manager still cleans up. - Convert a class-based context manager into an
@contextmanagergenerator (or the reverse) and compare the two versions.
What's next?
Decorators wrap behaviour and context managers wrap scope; both work best when the contracts are written down. Next you write them literally — Type Hints in Python shows how to annotate functions, containers and optional values so a checker can read your intentions.
Frequently Asked Questions
Python is a general-purpose language first released in 1991, designed around one idea: code is read far more often than it is written. English keywords and indentation instead of braces make it readable, the standard library covers files, JSON, dates and testing, and pip adds thousands more packages. The same language handles scripts, web APIs, data work and machine learning.
None at all. Python is widely considered the friendliest first language: no declaration keywords, no braces, no build step, and error messages that name the problem. This series begins at your first print() call. If you already read JavaScript or TypeScript you will adjust quickly, but no previous language is assumed anywhere in these 24 chapters.
Python 3 only, any release from 3.12 onwards. Python 2 reached end of life in 2021 and its print "hi" statements and xrange() no longer run. One historical quirk remains: installers ship the interpreter as python3 on macOS and Linux, while Windows usually offers python or the py launcher, and every command in this series works with that one-word swap.
More than you might expect. Everyday automation such as renaming files, cleaning spreadsheets or scraping pages; web backends with Django, Flask or FastAPI; data analysis and machine learning with pandas, NumPy and PyTorch; DevOps tooling, desktop apps and games. Python is also the classic glue language, calling C, Rust or Fortran libraries from short readable scripts.
Pure Python can run 10 to 100 times slower than C, yet that rarely blocks you: heavy numeric work lives in C extensions such as NumPy, which Python coordinates. Dynamic typing does push errors to runtime, surfacing as a TypeError on the line that finally uses the bad value. Type hints checked by mypy move those mistakes back into your editor.
Only the interpreter. Install CPython from python.org, version 3.12 or newer, confirm it with python3 --version, and open a plain text editor; VS Code with the Python extension is the recommendation here. The early chapters use no third-party packages, so there is nothing else to set up until virtual environments and pip arrive.
A dict is a real runtime mapping: keys may be any hashable value rather than only strings, insertion order has been guaranteed since Python 3.7, and a missing key raises KeyError unless you ask with get(). JavaScript objects coerce every key to a string and carry a prototype chain. A TypeScript interface is erased at compile time and describes shape only; TypedDict is Python's checked equivalent.
Reach for a list when items will be added, removed or reordered: it is ordered, mutable and rich with methods. Pick a tuple for fixed records and function returns, because it is immutable and hashable, so it can be a dictionary key. Choose a set for unique values and fast membership checks; it has no order and de-duplicates on creation.
Yes, gradually. Annotate public function signatures first; the interpreter ignores hints at runtime, so a checker such as mypy is what gives them teeth. Add tests the same way: pytest collects any test_*.py file and plain assert statements, and a suite that finishes in a tenth of a second is one you will run after every change. Both habits cost little now and much more later.
JavaScript and TypeScript keep the browser, so anything rendered to the user stays theirs. Python takes the server: Django for full-featured applications, Flask for small ones, FastAPI for typed async APIs where your annotations become automatic documentation. Your TypeScript habits transfer directly, and asyncio mirrors the event loop you already know, just with different keywords.