Decorators and context managers are Python's main mechanisms for cross-cutting concerns: logging, timing, caching, resource management. Learn the templates.
Context managers — with statements
with open("data.txt") as f:
data = f.read()
# File is automatically closed, even if exception raised
The with block guarantees cleanup. Critical for:
- File handles.
- Database connections.
- Network sockets.
- Locks.
- Anything that needs cleanup.
Writing a context manager — class-based
class Timer:
def __enter__(self):
self.start = time.time()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
elapsed = time.time() - self.start
print(f"Elapsed: {elapsed:.3f}s")
# Return False/None to propagate exception; True to suppress
return False
with Timer():
do_work()
Writing one — @contextmanager decorator
Easier for simple cases:
from contextlib import contextmanager
@contextmanager
def timer():
start = time.time()
try:
yield
finally:
elapsed = time.time() - start
print(f"Elapsed: {elapsed:.3f}s")
with timer():
do_work()
yield is where the with block executes. Setup before yield; cleanup after.
Nesting context managers
with open("in.txt") as fin, open("out.txt", "w") as fout:
fout.write(fin.read())
Multiple resources, single statement.
ExitStack — dynamic context managers
from contextlib import ExitStack
with ExitStack() as stack:
files = [stack.enter_context(open(f)) for f in filenames]
# all files closed when stack exits
Useful when number of context managers is dynamic.
Async context managers
async def main():
async with httpx.AsyncClient() as client:
response = await client.get("https://api.com")
Define with __aenter__ / __aexit__ or @asynccontextmanager.
Decorators
A decorator wraps a function to add behavior.
Simple decorator
def log_calls(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"Returned {result}")
return result
return wrapper
@log_calls
def add(a, b):
return a + b
add(1, 2)
# Calling add
# Returned 3
@log_calls is equivalent to add = log_calls(add).
Preserve metadata with @wraps
from functools import wraps
def log_calls(func):
@wraps(func) # preserves __name__, __doc__, signature
def wrapper(*args, **kwargs):
...
return wrapper
Without @wraps, the wrapped function loses its identity (name becomes "wrapper", docstring lost). Always use @wraps.
Decorator with arguments
def retry(times: int):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(times):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == times - 1:
raise
time.sleep(2 ** attempt)
return wrapper
return decorator
@retry(times=3)
def flaky_api_call():
...
Three layers: outer takes args; middle takes the function; inner is the wrapped behavior.
Common useful decorators
@functools.cache # cache call results (replaces lru_cache(None))
@functools.lru_cache(maxsize=100) # cache with LRU eviction
@property # method becomes attribute access
@staticmethod # class method without self/cls
@classmethod # class method receiving cls
@dataclass # auto __init__, __repr__, __eq__
@pytest.fixture # pytest test fixture
@contextmanager # turn generator into context manager
Class-based decorator
For stateful decorators:
class CountCalls:
def __init__(self, func):
self.func = func
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
return self.func(*args, **kwargs)
@CountCalls
def hello():
print("hi")
hello()
print(hello.count) # 1
Production patterns
Pattern 1: Resource management
@contextmanager
def transaction(db):
db.begin()
try:
yield db
db.commit()
except Exception:
db.rollback()
raise
with transaction(db) as tx:
tx.execute("INSERT ...")
Auto-commit-or-rollback. Standard pattern in many DB libraries.
Pattern 2: Timing
@contextmanager
def timed(label):
start = time.perf_counter()
try:
yield
finally:
print(f"{label}: {(time.perf_counter() - start)*1000:.1f}ms")
with timed("compute"):
heavy_compute()
Pattern 3: Retry
from functools import wraps
def retry(times=3, delay=1):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(times):
try:
return func(*args, **kwargs)
except Exception:
if attempt == times - 1:
raise
time.sleep(delay * (2 ** attempt))
return wrapper
return decorator
@retry(times=3)
def fetch_data():
...
In production: use tenacity library (handles retries, exponential backoff, jitter, conditions).
Pattern 4: Memoization
from functools import cache
@cache
def fibonacci(n):
if n < 2: return n
return fibonacci(n-1) + fibonacci(n-2)
Caches results based on args. Args must be hashable. cache is unbounded; lru_cache(maxsize=N) evicts.
Pattern 5: Validation / type-checking
def validate_types(func):
@wraps(func)
def wrapper(*args, **kwargs):
hints = func.__annotations__
for name, value in zip(func.__code__.co_varnames, args):
if name in hints and not isinstance(value, hints[name]):
raise TypeError(f"{name} must be {hints[name]}")
return func(*args, **kwargs)
return wrapper
Runtime type checking via decorator. Real libraries: typeguard, pydantic.
Async decorators
For async functions, the wrapper must also be async:
def log_async(func):
@wraps(func)
async def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
return await func(*args, **kwargs)
return wrapper
@log_async
async def fetch():
...
Common decorator/context-manager mistakes
- Forgetting
@wraps. Wrapped function loses metadata; breaks introspection. - Closing resources without
with. Leaks on exception. - Mutating args in decorator. Confusing; do it explicitly.
- Stateful decorators without considering threads. Race conditions.
- Decorating wrong type (sync wrapper for async function or vice versa).
Takeaway
Context managers (with) for resource management; write with class (__enter__/__exit__) or @contextmanager. Decorators wrap functions to add behavior; use @functools.wraps to preserve metadata. Common decorators: @cache, @property, @dataclass, @retry, @contextmanager. Production patterns: transaction, timing, retry, memoization, validation. Async versions exist (@asynccontextmanager, async wrappers).
Production Note: Optional Decorator Arguments Pattern (@dec vs @dec(arg))
A decorator that supports both bare syntax (@retry) and parameterized syntax (@retry(max_attempts=5)) requires a dual-dispatch wrapper:
import functools
def retry(_func=None, *, max_attempts: int = 3):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(max_attempts):
try:
return func(*args, **kwargs)
except Exception:
if attempt == max_attempts - 1:
raise
return wrapper
# Bare call: @retry -> _func is the function being decorated
if _func is not None and callable(_func):
return decorator(_func)
# Parameterized call: @retry(max_attempts=5) -> returns the decorator
return decorator