Python Decorators Explained
Learn how Python decorators work from first principles — functions as objects, wrapping callables, preserving metadata with functools.wraps, and writing decorators that take arguments.
What you'll learn
- ✓Why functions are first-class objects in Python
- ✓How the @decorator syntax desugars
- ✓Writing a simple logging decorator
- ✓Preserving function metadata with functools.wraps
- ✓Creating decorators that accept arguments
Prerequisites
- •Basic Python familiarity
A decorator is a function that takes another function and returns a new function with extra behaviour wrapped around it. The @decorator syntax you see above function definitions is just syntactic sugar for a very ordinary pattern that becomes natural once you understand how Python treats functions.
Functions Are Just Objects
In Python, functions are first-class objects. You can assign them to variables, pass them into other functions, and return them from functions. This is the foundation that makes decorators possible.
def greet(name):
return f"Hello, {name}"
say_hi = greet
print(say_hi("Ada")) # Hello, Ada
Because greet is an object, we can wrap it. A decorator does exactly that: it accepts a function, defines a new function that calls the original with extra behaviour, and returns the new function.
Your First Decorator
Here is the simplest useful decorator — one that logs every call to a function.
def log_calls(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__} with {args} {kwargs}")
result = func(*args, **kwargs)
print(f"Returned {result!r}")
return result
return wrapper
@log_calls
def add(a, b):
return a + b
add(2, 3)
The @log_calls line above add is equivalent to writing add = log_calls(add). Whenever you call add(2, 3), you are really calling wrapper(2, 3), which prints a message, calls the original add, prints the result, and returns it.
Why *args and **kwargs
The wrapper function uses *args and **kwargs so that it can decorate any function regardless of its signature. If you knew add always took exactly two arguments, you could write def wrapper(a, b) — but most decorators are written generically so they work everywhere.
Preserving Metadata with functools.wraps
There is a subtle problem with the decorator above. After decoration, the wrapped function’s __name__, __doc__, and other attributes belong to wrapper, not the original function.
print(add.__name__) # wrapper — not what we want
The standard fix is functools.wraps, which copies metadata from the original function onto the wrapper.
import functools
def log_calls(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@log_calls
def add(a, b):
"""Add two numbers."""
return a + b
print(add.__name__) # add
print(add.__doc__) # Add two numbers.
Always use functools.wraps. It costs nothing and prevents confusing debugging sessions later.
Decorators with Arguments
Sometimes you want the decorator itself to be configurable — for example, a repeat(n) decorator that runs a function several times. This requires one more layer.
import functools
def repeat(times):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(times=3)
def greet(name):
print(f"Hi {name}")
greet("Ada")
Read it from the inside out. repeat(times=3) returns decorator. decorator(greet) returns wrapper. So greet = repeat(times=3)(greet). The three levels of nesting feel awkward at first, but they follow the same rule every time.
A Practical Example: Timing
Decorators shine when you want to add cross-cutting behaviour without polluting your business logic. Timing is a classic example.
import time
import functools
def timed(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.4f}s")
return result
return wrapper
@timed
def slow_sum(n):
return sum(range(n))
slow_sum(1_000_000)
You can drop @timed onto any function in your code and immediately see how long it takes — no editing of the function body required.
Stacking Decorators
You can apply more than one decorator. They are applied bottom-up: the one closest to the function is applied first.
@log_calls
@timed
def work():
return sum(range(100_000))
This is equivalent to work = log_calls(timed(work)). Reading the stack from bottom to top mirrors the call order.
When to Reach for a Decorator
Decorators are great for behaviour that is orthogonal to the function’s purpose: logging, timing, retrying, caching (see functools.lru_cache), authentication checks, and registering handlers. If you find yourself copy-pasting the same lines into the top and bottom of many functions, a decorator can clean that up.
Avoid hiding important business logic in a decorator. Anything that significantly changes how a function behaves should live where readers can see it. Used with care, decorators make code shorter and intent clearer.
Wrapping Up
Decorators look intimidating at first because of the nested functions, but the building blocks are simple: functions are objects, you can wrap them, and @ is just sugar. Once you have written two or three of your own, the pattern locks in for life.
Related articles
- Python Python Decorators Deep Dive
A practical tour of Python decorators: how they work under the hood, when to use them, and how to write decorators that preserve metadata, accept arguments, and stack cleanly.
- Python Python Decorators: The Beginner-Friendly Guide
A practical introduction to Python decorators — functions as objects, wrapping, the @ syntax, functools.wraps, a logging and timing decorator, and decorators with arguments.
- Python Lambda Functions in Python: When and Why
A practical guide to Python lambda functions — syntax, use with sorted, map, and filter, common patterns, and the situations where a named def is the clearer choice.
- Python Default and Keyword Arguments in Python
A practical guide to Python function arguments — defaults, keyword arguments, *args, **kwargs, positional-only and keyword-only parameters, and the mutable default gotcha.