Python Function Decorators
Learn to build and use decorators for functions in Python: understand the wrapper pattern, add behavior without modifying the original function, and apply decorators to logging, timing, and authentication. Includes a hands-on exercise and common troubleshooting.
Focus: build and use decorators for functions
You've written a function that does exactly what you need — say you've got a get_user_data() that fetches from an API — but now you need to log every call, or measure how long it takes, or check permissions before it runs. If you copy-paste the same logging code into every function, your codebase becomes brittle and noisy. Decorators solve that: they let you wrap a function with reusable behavior, adding capabilities without touching the original function's body. By the end of this lesson you'll build your own decorators, understand the @ syntax, and know how to apply them to real-world tasks like timing, logging, and access control.
The problem this lesson solves
When a project grows, you inevitably need to run the same logic before or after many functions: print a timestamp, verify a user is logged in, retry on failure, or cache results. The naive approach — adding the same three lines of print or if to every function — leads to:
- Code duplication: The same pattern lives in dozens of places.
- Maintenance pain: Change the logging format? You now have to update every function.
- Violation of DRY: You're repeating yourself across the codebase.
Pro tip: Duplication is the #1 smell that tells you “this needs a decorator.” If you see the same three lines at the start of five functions, that's a candidate for a decorator.
Without decorators, you might reach for a helper function that wraps the call — but the calling code still needs to manually call that wrapper. Decorators make the extra behavior invisible to the call site: you just put @log_me above your function, and it automatically gets the new superpower.
Core concept / mental model
Think of a decorator as a function that takes another function and returns a new function — one that often adds something before and/or after the original call.
Here's the mental model in three layers:
- The original function — your
do_work()that does the core job. - The wrapper function — a new function that runs extra code, calls the original, and may run more extra code afterward.
- The decorator factory — the outer function that receives the original and returns the wrapper.
A diagram in words:
@my_decorator
def greet(name):
print(f"Hello, {name}!")
Is equivalent to writing:
greet = my_decorator(greet)
Key insight:
@my_decoratoris just syntactic sugar. Python callsmy_decorator(greet), and the return value (a new function) gets bound to the namegreet. From then on, every call togreet()actually calls the wrapper thatmy_decoratorreturned.
Core definitions
- Decorator: A callable that accepts a function (or class) as an argument and returns a replacement.
- Wrapper: The inner function that the decorator defines; it contains the extra logic and calls the original.
@syntax: Placing@decorator_nameabove a function definition applies the decorator at definition time.
How it works step by step
Let's build a minimal decorator from scratch so you see every moving part.
Step 1: Write the outer (decorator) function
def my_decorator(func):
def wrapper():
print("Before the function runs.")
func()
print("After the function runs.")
return wrapper
my_decorator takes func (the original function). Inside, wrapper is defined — it adds behavior before and after calling func. Finally, wrapper is returned without being called.
Step 2: Decorate a function
def say_hello():
print("Hello!")
say_hello = my_decorator(say_hello)
Now say_hello is a reference to wrapper. When you call say_hello(), you actually call wrapper(), which prints the extra lines and then runs the original say_hello.
Step 3: Use the @ syntax (preferred)
@my_decorator
def say_hello():
print("Hello!")
This is exactly the same as step 2 — Python does the assignment for you.
Step 4: Calling the decorated function
say_hello()
Output:
Before the function runs.
Hello!
After the function runs.
Pro tip: The wrapper replaces your original function. If you want to preserve the original's metadata (name, docstring, etc.), use
functools.wraps— we'll cover that in the troubleshooting section.
Hands-on walkthrough
Let's apply decorators to three practical scenarios: timing, logging, and authentication. Each example is complete and runnable.
Example 1: Timing decorator
import time
def timer(func):
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
end = time.perf_counter()
print(f"{func.__name__} took {end - start:.4f} seconds")
return result
return wrapper
@timer
def slow_function():
total = sum(range(1000000))
return total
result = slow_function()
# output (approx): slow_function took 0.0345 seconds
print(f"Result: {result}") # Result: 499999500000
Note how the wrapper accepts *args and **kwargs so it can pass them through to the decorated function — this is essential so that decorators work with functions that take arguments.
Example 2: Logging decorator
def logger(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__} with args={args}, kwargs={kwargs}")
result = func(*args, **kwargs)
print(f"{func.__name__} returned {result}")
return result
return wrapper
@logger
def add(a, b):
return a + b
add(3, 5)
# output:
# Calling add with args=(3, 5), kwargs={}
# add returned 8
Example 3: Authentication decorator (simulated)
def require_auth(func):
def wrapper(user, *args, **kwargs):
if not user.get("is_authenticated", False):
print("Access denied: user not authenticated.")
return None
print(f"User {user['name']} authenticated — proceeding.")
return func(user, *args, **kwargs)
return wrapper
@require_auth
def view_dashboard(user):
print(f"Welcome to the dashboard, {user['name']}!")
# Authenticated user
admin = {"name": "Alice", "is_authenticated": True}
view_dashboard(admin)
# output:
# User Alice authenticated — proceeding.
# Welcome to the dashboard, Alice!
# Unauthenticated user
bob = {"name": "Bob", "is_authenticated": False}
view_dashboard(bob)
# output:
# Access denied: user not authenticated.
Pro tip: When your decorator needs to check something about the arguments (like
user), make the wrapper accept the same parameters as the original, or use*args, **kwargsand inspect them.
Compare options / when to choose what
Not all ways to add cross-cutting behavior are equal. Here's a comparison table:
| Approach | When to use | Pros | Cons |
|---|---|---|---|
| Plain decorator (this lesson) | Adding behavior before/after many functions | DRY, clean syntax, composable | Must understand closures; can lose function metadata |
Decorator with arguments (e.g. @log(level="INFO")) |
When the decorator needs configuration | Flexible, parameterized | More complicated to write (3 levels of nesting) |
Context manager (with statement) |
Setup/teardown around a single block, not per-function | Clean scoping, exception safe | Not reusable across functions without a wrapper |
| Functor / callable class | When state needs to be shared across calls | Thread-safe state, reusable | Verbose; less intuitive |
For most cases — logging, timing, caching, auth — a plain decorator is the right tool. If you need to pass options (like log level), reach for a decorator factory (a function that returns a decorator). We'll cover that in a later lesson.
Troubleshooting & edge cases
Problem: Lost function metadata
def decorator(func):
def wrapper():
return func()
return wrapper
@decorator
def greet():
"""Say hello"""
print("Hi!")
print(greet.__name__) # 'wrapper' (not 'greet')
print(greet.__doc__) # None (not '"Say hello"')
Fix: Use @functools.wraps — it copies __name__, __doc__, and other attributes from the original to the wrapper.
import functools
def decorator(func):
@functools.wraps(func)
def wrapper():
return func()
return wrapper
greet.__name__ # 'greet' ✓
greet.__doc__ # 'Say hello' ✓
Problem: Decorated function with varying arguments
If your wrapper doesn't accept *args, **kwargs, calling the decorated function with arguments will break.
Fix: Always use def wrapper(*args, **kwargs) in your decorator unless you're absolutely sure the decorated function takes no arguments.
Problem: Stacking multiple decorators
@timer
@logger
def fetch_data():
pass
Order matters: this applies logger to fetch_data, then timer to the result. Execution: timer( logger(fetch_data) ). The outermost decorator runs first when the decorated function is called.
Edge case: Decorating a method in a class
The self argument is passed as the first positional argument, so the usual *args, **kwargs pattern works fine. Just be careful about instance vs. class methods.
What you learned & what's next
You now understand how to build and use decorators for functions — from writing a wrapper that adds behavior, to applying the @ syntax, to handling arguments and preserving metadata. You've seen decorators used for timing, logging, and authentication in real-world scenarios.
Key takeaways:
- A decorator is a function that takes a function and returns a new function.
- The
@decoratorsyntax is equivalent tofunc = decorator(func). - Always use
*args, **kwargsin the wrapper to support arbitrary function signatures. - Use
@functools.wraps(func)to preserve original function metadata. - Decorators are the go-to tool for cross-cutting concerns like logging, timing, and access control.
What's next: In the next lesson, you'll learn to build decorators with arguments (e.g. @retry(max_attempts=3)) using nested factories — an essential step for building reusable, configurable decorators in production code.
Practice recap
Write a decorator called retry that calls the decorated function up to 3 times if it raises a ValueError. If all attempts fail, let the exception propagate. Test it with a function that randomly raises ValueError. This exercise reinforces the wrapper pattern and handling exceptions inside decorators — a common real-world need.
Common mistakes
- Forgetting to return the wrapper function from the decorator — returning
wrapper()calls the function immediately and breaks the decorator. - Using
def wrapper():without*args, **kwargs— this breaks when the decorated function accepts arguments. - Omitting
@functools.wraps— the decorated function loses its original__name__and__doc__, causing confusion in debugging and documentation. - Assuming decorators run at call time, not definition time — the
@syntax executes the decorator function when the function is defined, not when it's called.
Variations
- Decorators with arguments: Use a factory function that returns a decorator, e.g.,
@log(level='INFO'). - Class-based decorators: Implement
__call__in a class to maintain state across decorated function calls. - Decorators on classes:
@dataclassis a built-in decorator that transforms a class — decorators aren't limited to functions.
Real-world use cases
- Logging every API call in a web framework like Flask or Django — a
@log_requestdecorator prints method, path, and response time. - Measuring execution time of functions in a data pipeline to identify bottlenecks and optimize performance.
- Checking user authentication before granting access to sensitive views in a web app — a
@login_requireddecorator redirects unauthenticated users.
Key takeaways
- A decorator is a function that takes a function and returns a new function, often a wrapper adding behavior before/after the original.
- The
@decoratorsyntax is syntactic sugar forfunc = decorator(func). - Always accept
*args, **kwargsin the wrapper to handle any function signature. - Use
@functools.wraps(func)on the wrapper to preserve the original function's name, docstring, and metadata. - Decorators are ideal for cross-cutting concerns like logging, timing, caching, and access control, keeping your code DRY.
- Multiple decorators stack from bottom to top: the one closest to the function definition runs first.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.