Python Course
Decorators
A decorator is a function that wraps another function to extend or modify its behaviour — without changing the original function's code at all. You have already seen decorators in action: @classmethod, @staticmethod, @property, and @abstractmethod are all built-in decorators. Now you will learn how they work from the ground up, and how to build your own.
Decorators are used everywhere in professional Python — logging, authentication, caching, rate limiting, input validation, timing, and more. Understanding them unlocks a huge portion of real-world Python codebases.
Functions Are First-Class Objects
Before decorators make sense, you need one foundational idea: in Python, functions are objects. You can assign them to variables, pass them as arguments, return them from other functions, and store them in data structures — just like integers or strings.
# Functions are objects — assign, pass, and return them
def greet(name):
return f"Hello, {name}!"
# Assign to a variable
say_hello = greet
print(say_hello("Alice")) # Hello, Alice!
# Pass as an argument
def shout(func, name):
return func(name).upper()
print(shout(greet, "Bob")) # HELLO, BOB!
# Return from a function — factory pattern
def make_greeter(prefix):
def inner(name):
return f"{prefix}, {name}!"
return inner # return the function itself, not its result
hi = make_greeter("Hi")
hey = make_greeter("Hey")
print(hi("Carol")) # Hi, Carol!
print(hey("Dave")) # Hey, Dave!HELLO, BOB!
Hi, Carol!
Hey, Dave!
- A function defined inside another function is called a closure — it remembers the enclosing scope's variables
- Returning a function (not calling it) is the key pattern that makes decorators possible
- The
make_greeterpattern — a function that creates and returns a customised function — is called a factory function
How a Decorator Works
A decorator is a function that takes a function as its argument, defines a wrapper function inside, and returns the wrapper. The @ syntax is clean shorthand for applying it.
Why it exists: decorators let you add behaviour to many functions at once without copying code into each one. The cross-cutting concern — logging, timing, auth — lives in one place.
Real-world use: every Flask and FastAPI route uses a decorator (@app.route(), @router.get()) to register a function as a URL handler without any boilerplate registration code.
# Building a decorator from scratch
def shout_decorator(func):
"""Wraps a function so its return value is uppercased."""
def wrapper(*args, **kwargs):
result = func(*args, **kwargs) # call the original function
return result.upper() # modify and return the result
return wrapper # return wrapper, not its result
# Apply manually — equivalent to what @ does
def greet(name):
return f"Hello, {name}!"
greet = shout_decorator(greet) # greet is now the wrapper
print(greet("Alice")) # HELLO, ALICE!
# Apply with @ syntax — cleaner, identical result
@shout_decorator
def farewell(name):
return f"Goodbye, {name}!"
print(farewell("Bob")) # GOODBYE, BOB!GOODBYE, BOB!
@shout_decoratorabove a function is exactly equivalent tofunc = shout_decorator(func)after it*args, **kwargsin the wrapper lets it accept any arguments the original function takes- The original function is still called inside the wrapper — decorators wrap, they do not replace
Preserving Metadata — functools.wraps
Without extra care, wrapping a function loses its name, docstring, and other metadata — because the wrapper replaces it. functools.wraps fixes this with one line and should always be used.
# functools.wraps — preserve the original function's metadata
from functools import wraps
def my_decorator(func):
@wraps(func) # copies name, docstring, etc.
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@my_decorator
def add(a, b):
"""Returns the sum of a and b."""
return a + b
print(add(2, 3)) # 5
print(add.__name__) # add ← correct, not 'wrapper'
print(add.__doc__) # Returns the sum of a and b.5
add
Returns the sum of a and b.
- Always use
@wraps(func)inside your decorator — it is a one-line best practice that prevents subtle bugs - Without it,
func.__name__returns'wrapper'and the docstring is lost — breaking introspection and documentation tools
1. Practical Example — Timing Decorator
A timing decorator measures how long any function takes to run. Apply it to any function with a single line.
# Timing decorator — measures execution time
import time
from functools import wraps
def timer(func):
@wraps(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}s")
return result
return wrapper
@timer
def slow_sum(n):
"""Sums all numbers from 0 to n."""
return sum(range(n))
result = slow_sum(10_000_000)
print("Result:", result)Result: 49999995000000
2. Practical Example — Logging Decorator
A logging decorator records every call to a function — its name, arguments, and return value — without touching the function's own code.
# Logging decorator — records every call
from functools import wraps
def log_calls(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"[LOG] Calling {func.__name__} | args={args} kwargs={kwargs}")
result = func(*args, **kwargs)
print(f"[LOG] {func.__name__} returned {result!r}")
return result
return wrapper
@log_calls
def divide(a, b):
return a / b
divide(10, 2)
divide(9, 3)[LOG] divide returned 5.0
[LOG] Calling divide | args=(9, 3) kwargs={}
[LOG] divide returned 3.0
Decorators with Arguments
Sometimes a decorator itself needs configuration — how many retries, which log level, what permission is required. A decorator with arguments adds one extra layer of nesting: a factory function that returns the decorator.
Real-world use: @retry(times=3) retries a failed network call up to three times before giving up — the number of retries is configured at decoration time.
# Decorator with arguments — three-layer structure
from functools import wraps
def repeat(times):
"""Decorator factory — returns a decorator that runs func `times` times."""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
result = None
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator # return the decorator, not the wrapper
@repeat(times=3)
def say(message):
print(message)
say("Hello!")Hello!
Hello!
- The three-layer structure: outer function takes decorator arguments → middle takes the function → inner wrapper runs it
@repeat(times=3)first callsrepeat(3)which returnsdecorator, then Python applies that tosay- This is the pattern behind Flask's
@app.route("/path")and pytest's@pytest.mark.parametrize
Stacking Multiple Decorators
You can apply more than one decorator to the same function by stacking them. Python applies them bottom-up — the decorator closest to the function is applied first.
# Stacking decorators — applied bottom-up
from functools import wraps
def bold(func):
@wraps(func)
def wrapper(*args, **kwargs):
return "**" + func(*args, **kwargs) + "**"
return wrapper
def uppercase(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs).upper()
return wrapper
@bold # applied second — outermost wrapper
@uppercase # applied first — innermost wrapper
def greet(name):
return f"hello, {name}"
print(greet("Alice"))
# uppercase wraps greet first → "HELLO, ALICE"
# bold wraps that result → "**HELLO, ALICE**"- Read stacked decorators bottom to top — the bottom one wraps the function first
- Each decorator receives the already-wrapped version from the decorator below it
- Keep stacks short — three or more decorators can make execution order hard to follow
Class-Based Decorators
A decorator does not have to be a function. Any callable works — and a class with __call__ is callable. Class-based decorators are useful when the decorator needs to maintain state between calls.
# Class-based decorator — maintains state between calls
from functools import wraps
class CallCounter:
"""Counts how many times the decorated function has been called."""
def __init__(self, func):
wraps(func)(self) # copy metadata onto self
self.func = func
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
print(f"[{self.func.__name__}] call #{self.count}")
return self.func(*args, **kwargs)
@CallCounter
def process(data):
return data.strip()
process(" hello ")
process(" world ")
print("Total calls:", process.count)[process] call #2
Total calls: 2
- The class receives the function in
__init__and calls it in__call__ - Instance attributes like
self.countpersist across calls — impossible with a plain function decorator - Class decorators work identically with
@syntax
Summary Table
| Pattern | Structure | Use When |
|---|---|---|
| Basic decorator | def dec(func): def wrapper(...): ... return wrapper |
Adding fixed behaviour to any function |
| With arguments | Three nested functions — factory → decorator → wrapper | Configurable behaviour at decoration time |
| Stacked decorators | @dec1 above @dec2 above function |
Combining independent concerns |
| Class-based | class Dec: def __init__(self, func): ... def __call__(self, ...): ... |
Decorator needs to maintain state |
@wraps(func) |
Applied to the wrapper inside the decorator | Always — preserves name, docstring, metadata |
Practice Questions
Practice 1. What does the @ symbol before a function definition do?
Practice 2. Why should you always use @wraps(func) inside a decorator?
Practice 3. In what order are stacked decorators applied?
Practice 4. What is a decorator factory?
Practice 5. Why would you use a class-based decorator over a function-based one?
Quiz
Quiz 1. What does a decorator function always return?
Quiz 2. Why does the wrapper inside a decorator use *args, **kwargs?
Quiz 3. Which module provides the wraps helper for decorators?
Quiz 4. Given @bold above @uppercase above a function, which decorator's wrapper is the outermost?
Quiz 5. What built-in Python decorators have you already used in this course?
Next up — Context Managers: managing resources cleanly with the with statement.