Decorators and Context Managers
Wrap behaviour without rewriting it: write decorators that keep their metadata, stack them in the right order, build context managers that release resources on every path, and manage a dynamic set of resources with ExitStack
The billing service had a clean function that charged a card. Then the team
added a retry, a timer, an audit log and a permission check. Each one arrived as
a separate copy of the same wrapper, pasted around a different call site. After
a year the charge function had four nested try blocks, three retry policies
and a log line that printed the wrong function name, because every wrapper had
forgotten to copy the original.
Decorators and context managers add behaviour without editing the function that provides it. A decorator wraps a callable, so logging, timing, caching and retry logic live in one place. A context manager wraps a block, so opening, closing and rolling back a resource happen on every exit path. Both rest on one idea: functions are objects, and an object can be passed, stored and replaced.
This lesson explains what the @ syntax assigns, why metadata has to be copied
by hand, why a decorator with arguments has three layers, and how __exit__
decides whether an exception is suppressed.
Key terms
- A function that can be assigned to a name, passed as an argument and returned from another function like any other object.
- A nested function that keeps a reference to the variables of the enclosing scope where it was defined.
- A callable that receives a function and returns a replacement function, applied with the @ syntax at definition time.
- The inner function a decorator returns, which usually calls the original function and adds behaviour around it.
- An object that implements __enter__ and __exit__ so a with statement can set up and tear down a resource.
- The __exit__ method, which receives the exception details and returns a true value to suppress the exception.
- A context manager built by @contextmanager from a generator that yields exactly once between setup and cleanup.
- A context manager that keeps a stack of cleanup callbacks and runs them in reverse order when the block ends.
- The attributes of a function such as __name__, __doc__ and the signature that tools and tracebacks read.
- A context manager that can be entered again while an earlier use is still active, such as suppress or redirect_stdout.
First-class function
Closure
Decorator
Wrapper
Context manager
Exit method
Generator context manager
ExitStack
Metadata
Reentrant
1. Functions Are Objects
A function is an object with attributes. It can be assigned to another name,
stored in a list, passed to a function and returned from one. The call syntax
name(...) operates on that object, not on the name.
def square(value: int) -> int:
return value * value
operation = square
print(operation.__name__)
print(operation(6))
print([callable(item) for item in (square, 42)])The output is square, then 36, then [True, False]. Both names refer to the
same function object, and callable reports whether an object can be called.
That is the foundation of the decorator protocol: if a function can be passed as
an argument, another function can accept it and return a replacement.
A nested function can also read a name from the enclosing scope after that scope
has returned, because the name is captured in the function object. That is a
closure, and the wrapper in the next section is one: it keeps func alive long
after the decorator returns.
A closure captures the name, not the value at definition time. The late
binding trap from lesson 5, where every lambda returned 2, is the same
mechanism seen from the other side.
2. The Decorator Protocol
A decorator is a callable that takes a function and returns a replacement. The
@ syntax is shorthand for a call followed by an assignment. The two snippets
below are equivalent, and the second is what the interpreter actually
does.
def logger(func):
def wrapper(*args, **kwargs):
print(f"calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
def add(a: int, b: int) -> int:
return a + b
add = logger(add)
print(add(3, 5))The output is calling add and then 8. Writing @logger above def add
produces the same object without repeating the name three times. Decoration
happens once, when the def statement runs at import. The wrapper body runs on
every call.
Description
A flowchart of what happens when a decorated function is defined. The def statement creates the original function object, the decorator receives it and returns a wrapper that closes over it, and the original name is rebound to the wrapper. At call time the wrapper runs its prefix, calls the original function, runs its suffix, and returns the result.
The *args, **kwargs signature is what makes a wrapper general. Without it the
wrapper would accept only the arguments it names, and decorating a function
with a different signature would fail at call time. The wrapper forwards what
it received and returns what the original returned.
A decorator runs at import time and its wrapper runs at call time. Work that should happen once, such as reading a configuration value or validating a registration, belongs in the decorator body. Work that should happen per call belongs in the wrapper.
A decorator does not have to call the original at all. It can replace it,
register it and return it unchanged, or wrap it several times. The protocol only
requires a callable in and a callable out, which is why the standard library
builds @property, @staticmethod, @functools.cache and @dataclass on one
mechanism.
3. Metadata, wraps and Introspection
The wrapper above has a problem that no test will catch. Its name is wrapper,
its docstring is None, and its signature is (*args, **kwargs). Every tool
that reads those attributes now describes the wrong function.
import inspect
def plain_logger(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@plain_logger
def greet(name: str, punctuation: str = "!") -> str:
"""Greet someone politely."""
return f"Hello, {name}{punctuation}"
print(greet.__name__)
print(greet.__doc__)
print(inspect.signature(greet))The output is wrapper, then None, then (*args, **kwargs). A traceback from
inside greet also points at wrapper, and the help page shows no parameters,
so the function is harder to use without being broken.
functools.wraps fixes the metadata by copying it from the original. It is a
decorator factory for update_wrapper, which assigns __module__, __name__,
__qualname__, __annotations__, __type_params__ and __doc__, updates
__dict__, and adds a __wrapped__ attribute that points at the
original.
import functools
import inspect
def logger(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@logger
def greet(name: str, punctuation: str = "!") -> str:
"""Greet someone politely."""
return f"Hello, {name}{punctuation}"
print(greet.__name__)
print(greet.__doc__)
print(inspect.signature(greet))
print(greet.__wrapped__.__name__)The output is greet, then Greet someone politely., then
(name: str, punctuation: str = '!') -> str, then greet. The signature is
correct because inspect.signature follows __wrapped__ by default, and
passing follow_wrapped=False shows the wrapper's real signature when a tool
needs it.
The video below writes a decorator from scratch and shows the difference that
functools.wraps makes to the function name and the help output.
Description
A video lesson that defines a decorator, applies it with the at syntax, and then shows how functools.wraps restores the original function name, docstring and signature.
A wrapper without functools.wraps breaks more than documentation. Frameworks
that dispatch on __name__, caches keyed by the function object, and test
reports that list test names all read the copied attributes. Add wraps in
the first version, not after a bug report.
4. Decorators With Arguments and Decorators as Objects
@repeat(times=3) is a different shape from @logger. The parentheses mean the
expression after the @ is evaluated first, and its result is used as the
decorator. That adds a third layer: an outer factory that stores the arguments,
a middle decorator that receives the function, and an inner wrapper that runs
per call.
import functools
def repeat(times: int = 2):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = None
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(times=3)
def announce(message: str) -> str:
return message.upper()
print(announce("ready"))The output is READY, and the function ran three times. repeat(3) returns
decorator, decorator(announce) returns wrapper, and the name announce is
bound to that wrapper.
The trap is that a factory used without parentheses silently decorates nothing
useful. @repeat passes the function itself as times and returns decorator,
so the name ends up bound to the middle layer. The first call then fails with a
TypeError, far from the mistake, because decorator expects a function.
The fill in the blank exercise checks the three layers and their responsibilities.
How to answer
Fill-in-the-blank exercise with 4 blanks. Type the missing words, then check your answer.
Complete the description of a decorator that takes arguments.
The outer factory receives the blank and returns a decorator. The middle layer receives the blank and returns a wrapper. The inner wrapper runs on every blank, calls the original function, and returns its blank.
Word Bank, drag words into the blanks above:
A decorator can also be an instance of a class that implements __call__. That
form is useful when the wrapper needs state between calls, such as a counter or
a cache.
import functools
class CountCalls:
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
return self.func(*args, **kwargs)
@CountCalls
def say(message: str) -> None:
print(message)
say("hi")
say("bye")
print(say.count, say.__name__)The output is hi, bye, then 2 say. update_wrapper copies the metadata
onto the instance, which works because a normal instance has a writable
__dict__. Without it, say.__name__ would raise AttributeError, because an
instance does not inherit the wrapped function's name.
The instance is created once, at decoration time, so say.count is shared by
every call. That is the point and also the hazard. A decorated function called
from several threads needs a lock around the counter, and a decorated function
used as a dictionary key is keyed by the instance rather than the original.
Name the layers. Use decorator for the middle function and wrapper for the
inner one, and keep the factory body free of work that should happen per call.
5. Stacking Decorators and Their Order
Several decorators can be stacked on one function. They are applied from the bottom up, because the syntax is repeated function application, and they run from the outside in at call time.
import functools
trace = []
def mark(label):
def decorator(func):
trace.append(f"{label} applied")
@functools.wraps(func)
def wrapper(*args, **kwargs):
trace.append(f"{label} before")
result = func(*args, **kwargs)
trace.append(f"{label} after")
return result
return wrapper
return decorator
@mark("outer")
@mark("inner")
def target():
trace.append("target body")
target()
print(trace)The output is ['inner applied', 'outer applied', 'outer before', 'inner before', 'target body', 'inner after', 'outer after']. Application runs bottom up, so
inner wraps the function first and outer wraps the result. Calls enter the
outermost wrapper first, which is why outer before appears before inner before.
Description
A sequence diagram of two stacked decorators. The caller calls the outer wrapper, which logs its prefix and calls the inner wrapper, which logs its prefix and calls the original function. The original returns to the inner wrapper, which logs its suffix, then the outer wrapper logs its suffix and returns to the caller.
Order changes behaviour when the layers are not symmetric. A permission check should sit outside a cache, so an unauthorised caller never reads a cached value. A retry should sit outside a timer, so the timer measures each attempt. A log decorator should sit outside everything else to record inner failures.
Stacking is not free. Each layer is another call, another frame and a little more latency. The chart below measures a one line function called two million times per configuration on CPython 3.14.4.
Description
Bar chart of measured call latency for a trivial function with zero, one, two and four wrapper layers. The undecorated call takes about 39 nanoseconds, one layer about 136, two layers about 231 and four layers about 358.
The numbers are specific to that interpreter and machine, so treat the shape as the lesson rather than the absolute values. A wrapper costs roughly one hundred nanoseconds here, which is irrelevant around a network call and significant inside a tight numeric loop. Measure before adding a fourth layer to a hot path.
Read a stack from the outside in when reasoning about behaviour, and from the
bottom up when reasoning about construction. The decorator written closest to
def is applied first and called last.
6. The with Protocol
A context manager is an object with two methods. __enter__ runs when the
with block starts and its return value is bound by the as clause.
__exit__ runs when the block ends, receives the exception details, and returns
a true value to suppress the exception. The
statement was added in Python 2.5 to replace the manual try/finally
pattern.
class Tracker:
def __init__(self, name: str, swallow: bool = False) -> None:
self.name = name
self.swallow = swallow
def __enter__(self):
print("enter", self.name)
return self
def __exit__(self, exc_type, exc, tb):
print("exit", self.name, exc_type.__name__ if exc_type else None)
return self.swallow
with Tracker("plain") as tracker:
print("body", tracker.name)
try:
with Tracker("quiet", swallow=True):
raise ValueError("swallowed")
print("after the quiet block")
except ValueError:
print("not suppressed")The output begins enter plain, body plain, exit plain None. Then the quiet
block prints enter quiet and exit quiet ValueError, the exception is
suppressed, and after the quiet block is printed. The try never sees it.
Description
A state diagram of a with statement. The context manager is created, enter runs and binds the as target, the block body runs, and exit runs on every path. Exit returns a false value and the exception propagates, or returns true and execution continues after the block.
The three arguments to __exit__ are None when no exception occurred. When
one did occur, exc_type is the class, exc is the instance and tb is the
traceback. Returning None or False lets the exception continue, and
returning True stops it.
Situation | __exit__ arguments | Return value | Result |
|---|---|---|---|
Block completed | None, None, None | Ignored | Execution continues |
Block raised | Class, instance, traceback | Falsy | Exception propagates |
Block raised | Class, instance, traceback | Truthy | Exception suppressed |
contextlib.suppress is the smallest useful context manager, and the honest way
to say that a specific failure is expected. It is reentrant, so nested uses
work.
import contextlib
with contextlib.suppress(FileNotFoundError):
open("/srv/reports/missing.csv")
print("survived")The output is survived. A KeyError from the same block would still
propagate, because only the named classes are suppressed. contextlib.nullcontext
is the opposite tool: it does nothing and returns its argument, which lets an
optional context manager be used without an if around the with.
Returning True from __exit__ hides the exception from every caller above
the block. Use that power for a named, expected failure such as a missing
optional file, not as a blanket way to make a red test turn green.
7. @contextmanager: A Generator Becomes a Context Manager
Writing a class for every short lived context is verbose.
contextlib.contextmanager turns a generator with exactly one yield into a
context manager. Everything before the yield is __enter__, and everything
after it is __exit__. If the block raises, the exception is raised at the
yield inside the generator, so a try/finally around the yield is the
cleanup path.
from contextlib import contextmanager
@contextmanager
def managed_file(path: str, mode: str = "r"):
handle = open(path, mode, encoding="utf-8")
try:
yield handle
finally:
handle.close()
with managed_file("/tmp/opencode/hello.txt", "w") as handle:
handle.write("shorter")The function is a generator, so calling it does not open the file. The with
statement drives it: __enter__ calls next, which runs to the yield and
returns the handle, then __exit__ resumes it after the yield and closes the
file. If the body raises, the exception is thrown in at the yield instead.
The try/finally is not optional. Without it, an exception in the body leaves
the generator suspended at the yield, and the cleanup line after it never
runs. The file then stays open until the generator is collected, which is not
deterministic, and a long running process can exhaust its descriptor limit
first.
Description
A flowchart contrasting a correct and an unsafe generator context manager. In both, enter runs to the yield and the block executes. On an exception, the exception is thrown at the yield. With a try finally the cleanup runs and the exception propagates, without it the cleanup is skipped and the resource leaks.
A generator context manager instance is single use. The helper discards the
stored call arguments after the first __enter__, so a second with on the
same object fails. Create a fresh instance in the with header, which is the
documented pattern.
@contextmanager also returns an object that inherits from ContextDecorator,
so the same generator can decorate a function. In the decorator form the helper
rebuilds the context manager on every call, which is why the single use rule
does not apply there.
The matching exercise below connects each part of the protocol to its role.
How to answer
Matching exercise with 5 pairs. Match each item on the left with its answer on the right.
Match each piece of the context manager protocol to what it does.
Drag answers from here:
Catch an exception inside a generator context manager only to log it or to
translate it. If the generator swallows the exception and does not re-raise
it, __exit__ reports success and the with statement continues as if
nothing failed.
8. ExitStack and Dynamic Resources
Sometimes the number of resources is not known until the block runs. A function
may need to open one file or twenty, or acquire a lock only when a flag is set.
contextlib.ExitStack keeps a stack of cleanup callbacks and unwinds it in
reverse order, which is the behaviour of nested with statements built at
runtime.
import contextlib
with contextlib.ExitStack() as stack:
stack.callback(print, "first registered")
stack.callback(print, "second registered")
print("body")
print("after the stack")The output is body, second registered, first registered, after the stack. Registration order is top to bottom and cleanup order is bottom to top,
which mirrors nested with blocks.
enter_context is the method for a real context manager. It calls __enter__,
registers __exit__, and returns the value that as would have bound. If a
later resource fails to open, the ones already registered are still released,
which is the all or nothing property that a loop of separate with blocks does
not give you.
The drag and drop exercise orders the events of a transaction context manager that runs cleanup on every exit path.
How to answer
Ordering exercise with 5 items. Move them into the correct order, then check your answer.
Order the events from the start of the with block to the end, assuming the body raises.
Use ExitStack.callback for a plain cleanup function, enter_context for a
context manager, and pop_all when the resources must be transferred to a
longer lived object instead of released at the end of the block.
9. Real-World Case Studies
Two production shapes combine this lesson with earlier ones: a retry decorator that classifies failures, and a transaction context manager that must not lie about success.
A retry decorator that only retries what can succeed
A retry belongs in a decorator because it is a policy about a call, not a part of the call. The version below is the exception policy from lesson 3, expressed as a wrapper with a configurable budget and a wait hook that a test can replace.
import functools
import time
class TransientError(Exception):
"""A failure that may succeed if the operation runs again."""
def retry(attempts: int = 3, base_delay: float = 0.5, wait=time.sleep):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, attempts + 1):
try:
return func(*args, **kwargs)
except TransientError:
if attempt == attempts:
raise
wait(base_delay * 2 ** (attempt - 1))
return wrapper
return decorator
calls = {"count": 0}
@retry(attempts=3, wait=lambda delay: print(f"waiting {delay:.1f}s"))
def fetch(record_id: int) -> str:
calls["count"] += 1
if calls["count"] < 3:
raise TransientError("temporary")
return f"record {record_id}"
print(fetch(7))The output is waiting 0.5s, waiting 1.0s, then record 7. The wrapper
retries only TransientError, so a ValueError fails on the first attempt.
When the budget runs out, the bare raise preserves the original traceback
instead of replacing it with a generic timeout. The wait hook is a parameter
rather than a hard-coded time.sleep, which keeps the test deterministic.
A transaction context manager that rolls back and re-raises
A transaction must commit only when the block succeeds, and it must roll back
and re-raise when the block fails. The context manager below makes that contract
explicit and keeps the call site free of try blocks.
import contextlib
events: list[str] = []
@contextlib.contextmanager
def transaction(log):
log.append("begin")
try:
yield log
except BaseException:
log.append("rollback")
raise
else:
log.append("commit")
with transaction(events):
events.append("insert")
print(events)The output is ['begin', 'insert', 'commit']. When the body raises, the
except branch rolls back and the bare raise sends the original exception
outward, so the caller sees the real failure and the log stays consistent. A
real database exposes begin, commit and rollback methods, and the shape of
the context manager is the same.
The except BaseException deserves a comment. It catches KeyboardInterrupt as
well, which is deliberate here: an interrupted transaction must still roll back
before the process dies. The handler re-raises immediately, so it does not
swallow the interrupt; it only guarantees the rollback runs first. A lock is the
other classic case, and threading.Lock is a context manager, so the release
happens on every exit path.
A decorator that caches results must be placed outside the retry only if the retry result is worth caching. A cache outside a retry stores the first success, while a retry outside a cache repeats a cached failure. Write the stack in the order you can defend in review.
10. Choosing Between Them, Pitfalls and a Review Checklist
Both features wrap something, and the choice is about what is being wrapped. A
decorator wraps a callable and runs once per call. A context manager wraps a
block and runs once per with, and it can hold state only for that block.
Description
A quadrant chart placing four wrapping tools by scope and state. A plain function decorator sits in the per call, low state corner. A class based decorator with a counter sits in the per call, high state corner. A generator context manager sits in the per block, low state corner. A class based context manager sits in the per block, high state corner.
The table below collects the failures that appear most often in review.
Pitfall | Symptom | Fix |
|---|---|---|
Wrapper without wraps | Tracebacks and help show wrapper | Add @functools.wraps(func) |
Factory used without parentheses | TypeError at first call | Use @repeat(times=3) |
Stack written in the wrong order | Auth checked after the cache | Reason from the outside in |
__exit__ returns True everywhere | Failures disappear silently | Return True only for expected errors |
@contextmanager without try/finally | Resource leaks on exception | Wrap the yield in try/finally |
Context manager instance reused | Second with fails | Create a new instance per use |
Cleanup registered after the work | Resource leaks when setup fails | Register each resource as it is acquired |
Decorator body does work per call | Slow import, stale configuration | Move it into the wrapper or the factory |
The checklist below is short enough to use on a diff.
- Does every wrapper call
functools.wrapson the function it replaces? - Does the wrapper forward
*args, **kwargsand return the original result? - Is the decorator written with the parentheses its factory requires?
- Is the stack order defensible for permissions, caching, retry and timing?
- Does every
__exit__returnFalseunless suppression is the point? - Does every generator context manager wrap its
yieldintry/finally? - Is every resource registered as soon as it is acquired, not at the end?
- Are dynamic resources managed by
ExitStackrather than a loop ofwithblocks?
A decorator changes the identity of a function. Anything that compares
callables, stores them in a dictionary, or pickles them sees the wrapper, not
the original. functools.wraps keeps the metadata but not the identity, so use
__wrapped__ when the original is needed.
2026 Updates (as of November 2026)
- Python 3.15.2 is the current stable documentation set.
- Since Python 3.15, decorating a generator or coroutine function with a
ContextDecoratorkeeps the context manager open across iteration or await, instead of closing it when the object is created. ExitStack.enter_contextandpushaccept arbitrary descriptors since Python 3.15, so objects that expose__enter__and__exit__through a descriptor work without a wrapper.functools.update_wrapperhas copied__type_params__since Python 3.12, alongside__name__,__qualname__,__annotations__and__doc__, which keeps generic functions readable through a decorator.contextlib.chdirhas been available since Python 3.11 and restores the previous working directory on exit, which replaces the hand written save and restore pattern.
π Did you know?
@functools.cachereturns the same kind of wrapper this lesson builds, and itscache_infoandcache_clearattributes are the reason a class-based decorator sometimes beats a closure.- A decorator can be applied to a class as well as a function.
@dataclassand@total_orderingreceive the class object and return a modified class, which is the same protocol applied to a different kind of object.
Comparative Verdict. Use a decorator when the behaviour belongs to a call
and should be reusable across functions: logging, timing, caching, retry and
permission checks. Use a context manager when the behaviour belongs to a
region of code and must run on every exit path: files, locks and transactions.
Use a class when the wrapper needs inspectable state, a closure when it does
not, and ExitStack when the number of resources is decided at runtime. Keep
the wrapper transparent: copy the metadata, return the result, re-raise what
you cannot repair.
Practice Questions
How to answer
Multiple-choice question with 4 options. Select one option, then choose Check Answer.
A module defines a function with @logger above def add. Which statement describes what the interpreter does with that decoration?
How to answer
Multiple-choice question with 4 options. Select one option, then choose Check Answer.
A decorator returns a wrapper without functools.wraps. Which observable problem appears first?
How to answer
Multiple-choice question with 4 options. Select one option, then choose Check Answer.
A factory is defined as def repeat(times=2) that returns a decorator. A developer writes @repeat without parentheses. What happens?
How to answer
Multiple-choice question with 4 options. Select one option, then choose Check Answer.
A function is decorated with @outer above @inner. Which order describes application and execution?
How to answer
Multiple-choice question with 4 options. Select one option, then choose Check Answer.
A class-based decorator stores a call counter and is applied to a function that several threads call. What must the author consider?
How to answer
Multiple-choice question with 4 options. Select one option, then choose Check Answer.
A with block raises ValueError and the context manager's __exit__ returns True. What does the code after the with block observe?
How to answer
Multiple-choice question with 4 options. Select one option, then choose Check Answer.
A generator context manager is written with a yield and a close call after it, with no try/finally. The with body raises. What is the consequence?
How to answer
Multiple-choice question with 4 options. Select one option, then choose Check Answer.
A function must open a list of files whose length is known only at runtime, and every opened file must be closed if a later open fails. Which tool fits?
How to answer
Multiple-choice question with 4 options. Select one option, then choose Check Answer.
A retry decorator catches TransientError and re-raises after the last attempt. Why does it use a bare raise instead of raise exc?
How to answer
Multiple-choice question with 4 options. Select one option, then choose Check Answer.
A permission check and a result cache are both implemented as decorators. Which order is safer, and why?
Key Takeaways
- A decorator is a callable that replaces a function at definition time. The
@syntax meansname = decorator(name), and the wrapper runs on every call. - Always apply
functools.wrapsto a wrapper, because__name__,__doc__,__annotations__and the signature are what tracebacks, help and test tools read. - A decorator with arguments has three layers: a factory, a decorator and a wrapper. Using the factory without parentheses binds the wrong object.
- Stacked decorators are applied bottom up and entered top down, so the order changes behaviour whenever the layers are not symmetric.
- A context manager implements
__enter__and__exit__. Returning a true value from__exit__suppresses the exception, and that choice should be rare and deliberate. - A generator context manager must wrap its single
yieldintry/finally, and each instance is single use. - Use a decorator for per-call behaviour and a context manager for per-block cleanup, and use
ExitStackwhen the number of resources is decided at runtime.
References
Every factual claim links to its source, with the access date printed so the source can be rechecked.