Python Decorators: Wrap Functions with Extra Behavior
Decorators are closures that take a function and return a wrapper. The @ syntax is just sugar. Priya connects the dots.
Before you explain this, I think I already see it. The @require_auth decorator is a function that takes our route handler, wraps it in a new function that checks the auth token first, and returns the wrapper. The original function never changes. The decorator just... stands in front of it like a bouncer.
I had a whole lead-up planned for this. You just skipped it.
I went back and looked at the codebase last night. Once you showed me closures yesterday, the decorator code stopped looking like magic. It's just a closure that happens to take a function as its argument.
That is literally the entire concept. Everything else — functools.wraps, stacking decorators, decorators with arguments — is details on top of that one idea.
But what does the @ symbol actually do?
It's syntactic sugar. This:
@require_auth
def get_user_profile():
return {"name": "Priya", "role": "developer"}
Is exactly the same as this:
def get_user_profile():
return {"name": "Priya", "role": "developer"}
get_user_profile = require_auth(get_user_profile)
You're passing the function to the decorator, and assigning the result back to the same name.
Okay so the @ is just... convenience notation. The real work is that require_auth takes a function and returns a different function.
Yes. And require_auth is a closure. It captures the auth logic, remembers it, and wraps it around the original function.
Show me a simple decorator from scratch.
Here's one that logs when a function is called:
def log_calls(func):
def wrapper(*args, **kwargs):
print(f"Calling: {func.__name__}")
return func(*args, **kwargs)
return wrapper
def greet(name):
return f"Hello, {name}!"
greet = log_calls(greet)
print(greet("Amir")) # Prints "Calling: greet" then returns "Hello, Amir!"
The decorator function takes func as an argument. Inside it, we define wrapper — a new function that remembers func in its closure. Wrapper calls func, with logging before it. Then we return wrapper.
So wrapper is the closure. It captures func, and every time you call wrapper, it runs the logging, then calls the original func.
Exactly.
But why use *args and **kwargs? I've never written that before.
Because you need the wrapper to work with any function, no matter how many arguments it takes. *args captures all positional arguments as a tuple. **kwargs captures all keyword arguments as a dictionary. Then you pass them through to the original function.
def greet(name, greeting="Hello"):
return f"{greeting}, {name}!"
@log_calls
def process_order(order_id, items, discount=0):
return {"id": order_id, "items": items, "discount": discount}
greet("Priya") # Works: wrapper(*args, **kwargs) becomes wrapper("Priya")
process_order(123, ["item1", "item2"], discount=10) # Works: wrapper captures all three args and kwargs
Without *args/**kwargs, you'd have to write a separate wrapper for each signature. That's not practical.
So every decorator has this pattern: capture the function in a closure, define a wrapper that does extra stuff, return the wrapper.
Yes. The decorator is the outer function. The wrapper is the closure. The wrapper remembers the original function and does something around it.
Okay but I have a real problem. When I use @log_calls, the function name becomes "wrapper". Watch:
@log_calls
def greet(name):
return f"Hello, {name}!"
print(greet.__name__) # Prints "wrapper", not "greet"
That's a real issue. When you replace the original function with the wrapper, you lose the function's metadata — its name, its docstring, everything that tells you what it actually does. That's why functools.wraps exists.
How does that work?
It copies the original function's metadata onto the wrapper:
from functools import wraps
def log_calls(func):
@wraps(func) # This decorator copies func's __name__, __doc__, etc. to wrapper
def wrapper(*args, **kwargs):
print(f"Calling: {func.__name__}")
return func(*args, **kwargs)
return wrapper
@log_calls
def greet(name):
"""Return a greeting."""
return f"Hello, {name}!"
print(greet.__name__) # Prints "greet", not "wrapper"
print(greet.__doc__) # Prints "Return a greeting."
So @wraps is a decorator that decorates the wrapper function?
Yes. It's decorators all the way down. @wraps takes the wrapper function and replaces its name and doc with the original function's.
But why does that matter? If I'm debugging, I can just print statements.
Because tools depend on it. Debuggers, documentation generators, testing frameworks — they all look at name and doc to figure out what they're looking at. Without @wraps, a stack trace shows "wrapper" instead of "greet", and you waste time wondering which function actually failed.
Okay so the rule is: always use @wraps when you write a decorator.
Always. It costs nothing and saves debugging time later.
Let me make sure I understand the full pattern. You write a decorator function that takes a function as an argument. Inside it, you define a wrapper function that does the extra behavior and calls the original. You decorate the wrapper with @wraps to preserve metadata. Then you return the wrapper.
That is the entire thing. Everything else — multiple decorators, decorators with arguments, class-based decorators — is building on that foundation.
So if I wanted to write a @timer decorator that measures how long a function takes...
Go ahead. What would it look like?
I'd need to import time. Then create a decorator function that takes a function. Inside it, define a wrapper that calls time.time() before and after, calculates the difference, prints it, and returns the result.
import time
from functools import wraps
def timer(func):
@wraps(func)
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
elapsed = time.time() - start
print(f"{func.__name__} took {elapsed:.2f}s")
return result
return wrapper
That's correct. You did skip my explanation.
Like I said — once closures made sense, decorators were just closures with a function argument and @ sugar.
Write a decorator that catches exceptions and logs them before re-raising.
def safe_call(func):
@wraps(func)
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except Exception as e:
print(f"Error in {func.__name__}: {e}")
raise
return wrapper
That works. You catch, log, and re-raise. The original exception keeps propagating.
So the pattern is: the decorator does something before, calls the function, does something after, and returns the result.
Or it can wrap the function call in try/except, or in a context manager, or any control flow you need. The decorator is responsible for wrapping the behavior.
Can you stack decorators? Like, use two at once?
Yes. Each decorator wraps the result of the previous one:
@safe_call
@timer
def expensive_operation():
# ... code ...
This is equivalent to:
def expensive_operation():
# ... code ...
expensive_operation = timer(expensive_operation)
expensive_operation = safe_call(expensive_operation)
So when you call expensive_operation, safe_call's wrapper runs first (catches exceptions), which calls timer's wrapper (measures time), which calls the original function.
Order matters then. The decorator closest to the function definition runs last in the chain.
Right. Read from bottom to top: @safe_call applied after @timer.
Okay I get it. Decorators are closures. The @ syntax is sugar. Use @wraps to keep metadata. Stack them if you need multiple behaviors. And tomorrow...?
Tomorrow: the patterns that real codebases use. Caching decorators, retry decorators, authentication, rate limiting. You're going to see that Amir's @require_auth is just the pattern you built today, with real authentication logic inside.
So I could write that decorator myself?
You could. And after tomorrow, you will.
Practice your skills
Sign up to write and run code in this lesson.
Python Decorators: Wrap Functions with Extra Behavior
Decorators are closures that take a function and return a wrapper. The @ syntax is just sugar. Priya connects the dots.
Before you explain this, I think I already see it. The @require_auth decorator is a function that takes our route handler, wraps it in a new function that checks the auth token first, and returns the wrapper. The original function never changes. The decorator just... stands in front of it like a bouncer.
I had a whole lead-up planned for this. You just skipped it.
I went back and looked at the codebase last night. Once you showed me closures yesterday, the decorator code stopped looking like magic. It's just a closure that happens to take a function as its argument.
That is literally the entire concept. Everything else — functools.wraps, stacking decorators, decorators with arguments — is details on top of that one idea.
But what does the @ symbol actually do?
It's syntactic sugar. This:
@require_auth
def get_user_profile():
return {"name": "Priya", "role": "developer"}
Is exactly the same as this:
def get_user_profile():
return {"name": "Priya", "role": "developer"}
get_user_profile = require_auth(get_user_profile)
You're passing the function to the decorator, and assigning the result back to the same name.
Okay so the @ is just... convenience notation. The real work is that require_auth takes a function and returns a different function.
Yes. And require_auth is a closure. It captures the auth logic, remembers it, and wraps it around the original function.
Show me a simple decorator from scratch.
Here's one that logs when a function is called:
def log_calls(func):
def wrapper(*args, **kwargs):
print(f"Calling: {func.__name__}")
return func(*args, **kwargs)
return wrapper
def greet(name):
return f"Hello, {name}!"
greet = log_calls(greet)
print(greet("Amir")) # Prints "Calling: greet" then returns "Hello, Amir!"
The decorator function takes func as an argument. Inside it, we define wrapper — a new function that remembers func in its closure. Wrapper calls func, with logging before it. Then we return wrapper.
So wrapper is the closure. It captures func, and every time you call wrapper, it runs the logging, then calls the original func.
Exactly.
But why use *args and **kwargs? I've never written that before.
Because you need the wrapper to work with any function, no matter how many arguments it takes. *args captures all positional arguments as a tuple. **kwargs captures all keyword arguments as a dictionary. Then you pass them through to the original function.
def greet(name, greeting="Hello"):
return f"{greeting}, {name}!"
@log_calls
def process_order(order_id, items, discount=0):
return {"id": order_id, "items": items, "discount": discount}
greet("Priya") # Works: wrapper(*args, **kwargs) becomes wrapper("Priya")
process_order(123, ["item1", "item2"], discount=10) # Works: wrapper captures all three args and kwargs
Without *args/**kwargs, you'd have to write a separate wrapper for each signature. That's not practical.
So every decorator has this pattern: capture the function in a closure, define a wrapper that does extra stuff, return the wrapper.
Yes. The decorator is the outer function. The wrapper is the closure. The wrapper remembers the original function and does something around it.
Okay but I have a real problem. When I use @log_calls, the function name becomes "wrapper". Watch:
@log_calls
def greet(name):
return f"Hello, {name}!"
print(greet.__name__) # Prints "wrapper", not "greet"
That's a real issue. When you replace the original function with the wrapper, you lose the function's metadata — its name, its docstring, everything that tells you what it actually does. That's why functools.wraps exists.
How does that work?
It copies the original function's metadata onto the wrapper:
from functools import wraps
def log_calls(func):
@wraps(func) # This decorator copies func's __name__, __doc__, etc. to wrapper
def wrapper(*args, **kwargs):
print(f"Calling: {func.__name__}")
return func(*args, **kwargs)
return wrapper
@log_calls
def greet(name):
"""Return a greeting."""
return f"Hello, {name}!"
print(greet.__name__) # Prints "greet", not "wrapper"
print(greet.__doc__) # Prints "Return a greeting."
So @wraps is a decorator that decorates the wrapper function?
Yes. It's decorators all the way down. @wraps takes the wrapper function and replaces its name and doc with the original function's.
But why does that matter? If I'm debugging, I can just print statements.
Because tools depend on it. Debuggers, documentation generators, testing frameworks — they all look at name and doc to figure out what they're looking at. Without @wraps, a stack trace shows "wrapper" instead of "greet", and you waste time wondering which function actually failed.
Okay so the rule is: always use @wraps when you write a decorator.
Always. It costs nothing and saves debugging time later.
Let me make sure I understand the full pattern. You write a decorator function that takes a function as an argument. Inside it, you define a wrapper function that does the extra behavior and calls the original. You decorate the wrapper with @wraps to preserve metadata. Then you return the wrapper.
That is the entire thing. Everything else — multiple decorators, decorators with arguments, class-based decorators — is building on that foundation.
So if I wanted to write a @timer decorator that measures how long a function takes...
Go ahead. What would it look like?
I'd need to import time. Then create a decorator function that takes a function. Inside it, define a wrapper that calls time.time() before and after, calculates the difference, prints it, and returns the result.
import time
from functools import wraps
def timer(func):
@wraps(func)
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
elapsed = time.time() - start
print(f"{func.__name__} took {elapsed:.2f}s")
return result
return wrapper
That's correct. You did skip my explanation.
Like I said — once closures made sense, decorators were just closures with a function argument and @ sugar.
Write a decorator that catches exceptions and logs them before re-raising.
def safe_call(func):
@wraps(func)
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except Exception as e:
print(f"Error in {func.__name__}: {e}")
raise
return wrapper
That works. You catch, log, and re-raise. The original exception keeps propagating.
So the pattern is: the decorator does something before, calls the function, does something after, and returns the result.
Or it can wrap the function call in try/except, or in a context manager, or any control flow you need. The decorator is responsible for wrapping the behavior.
Can you stack decorators? Like, use two at once?
Yes. Each decorator wraps the result of the previous one:
@safe_call
@timer
def expensive_operation():
# ... code ...
This is equivalent to:
def expensive_operation():
# ... code ...
expensive_operation = timer(expensive_operation)
expensive_operation = safe_call(expensive_operation)
So when you call expensive_operation, safe_call's wrapper runs first (catches exceptions), which calls timer's wrapper (measures time), which calls the original function.
Order matters then. The decorator closest to the function definition runs last in the chain.
Right. Read from bottom to top: @safe_call applied after @timer.
Okay I get it. Decorators are closures. The @ syntax is sugar. Use @wraps to keep metadata. Stack them if you need multiple behaviors. And tomorrow...?
Tomorrow: the patterns that real codebases use. Caching decorators, retry decorators, authentication, rate limiting. You're going to see that Amir's @require_auth is just the pattern you built today, with real authentication logic inside.
So I could write that decorator myself?
You could. And after tomorrow, you will.