Advanced PythonAdvanced10 min53 / 63

Decorators

Learn how decorators let you wrap any function in extra behavior — like logging or timing — without touching the original code.

Imagine you run a small cafe. Every barista has their own way of making coffee, but you want to time every drink and log it to a notebook — without retrain­ing anyone or rewriting their recipes.

That is exactly what a decorator does in Python. It wraps a function — any function — and quietly adds behavior around it. The original function never changes. The decorator just slips a new layer around the outside, like gift wrap.

See it in action

Visual walkthrough1 / 6
1

Gift-Wrap Any Function

A decorator adds behavior to a function — logging, timing, validation — without changing a single line of the original. It's a reusable wrapper you snap on with one line.

Think of it as gift wrap: the present inside never changes, you just add a new layer around it.

#Functions Are First-Class Objects

Before decorators make sense, you need to know one surprising thing: in Python, functions are values, just like numbers or strings. You can store a function in a variable, pass it to another function, or return it from a function. This is called being a first-class object.

greeter and say_hello point to the same function. No parentheses = no call.
def say_hello():
    print("Hello!")

# Store a function in a variable
greeter = say_hello

# Call it through the variable
greeter()
Passing a function as an argument to another function.
def run_twice(func):
    func()
    func()

def clap():
    print("*clap*")

run_twice(clap)
Think of it like

Functions as gift boxes

Think of a function as a gift box with instructions inside. You can hand that box to someone (pass it), put it on a shelf (store it in a variable), or even put it inside another box (return it from a function). The instructions do not run until someone opens the box — i.e., calls it with ().

#Returning a Function From a Function

Here is the other key idea: a function can create and return another function. The returned function is called a closure — it remembers the environment in which it was created.

make_multiplier returns a new function. Each returned function remembers its own 'n'.
def make_multiplier(n):
    def multiply(x):
        return x * n   # 'n' is remembered from the outer function
    return multiply

double = make_multiplier(2)
triple = make_multiplier(3)

print(double(5))
print(triple(5))

#Building Your First Decorator

A decorator is a function that:

  1. Takes a function as its argument
  2. Defines a wrapper function inside itself that adds behavior
  3. Returns the wrapper

The wrapper calls the original function, but can do things before and after.

shout_decorator wraps greet and adds lines before and after.
def shout_decorator(func):
    def wrapper():
        print(">>> Getting ready...")
        func()               # call the original
        print("<<< Done!")
    return wrapper

def greet():
    print("Hi there!")

# Wrap greet manually
greet = shout_decorator(greet)

greet()

#The @ Syntax — Decorator Sugar

Writing func = decorator(func) every time is repetitive. Python gives you the @ shorthand — place it right above the function definition and Python does the wrapping automatically. These two snippets do exactly the same thing.

@shout_decorator is syntactic sugar — just cleaner to read and write.
def shout_decorator(func):
    def wrapper():
        print(">>> Getting ready...")
        func()
        print("<<< Done!")
    return wrapper

@shout_decorator          # same as: greet = shout_decorator(greet)
def greet():
    print("Hi there!")

greet()

#Handling Any Function with *args and **kwargs

The shout_decorator above only works on functions with no arguments. What about functions that take parameters?

The trick is to make your wrapper accept *args and **kwargs and forward them straight through. This way the decorator works on any function, no matter its signature.

A universal timing decorator. *args and **kwargs let it wrap any function.
import time

def timer(func):
    def wrapper(*args, **kwargs):       # accept anything
        start = time.time()
        result = func(*args, **kwargs)  # forward everything
        end = time.time()
        elapsed = end - start
        print(f"{func.__name__} took {elapsed:.4f} seconds")
        return result                   # don't swallow the return value!
    return wrapper

@timer
def add(a, b):
    return a + b

total = add(10, 20)
print("Result:", total)
Common mistake

Always return the result!

If your wrapper calls func(*args, **kwargs) but forgets to return the result, the decorated function will silently return None. Always capture the return value in a variable and return it from the wrapper.

#Preserving Metadata with functools.wraps

When you wrap a function, Python can get confused about the function's name and documentation. The wrapper replaces the original, so func.__name__ and func.__doc__ point to the wrapper instead of the real function. The fix is one line: @functools.wraps(func) applied to your wrapper.

@functools.wraps keeps the original function's identity intact.
import functools

def timer(func):
    @functools.wraps(func)          # copies name, docstring, and more
    def wrapper(*args, **kwargs):
        import time
        start = time.time()
        result = func(*args, **kwargs)
        elapsed = time.time() - start
        print(f"{func.__name__} took {elapsed:.4f}s")
        return result
    return wrapper

@timer
def multiply(a, b):
    """Multiply two numbers."""
    return a * b

print(multiply.__name__)  # would be 'wrapper' without functools.wraps
print(multiply.__doc__)
Tip

Always add @functools.wraps

Make it a habit: every time you write a decorator, add @functools.wraps(func) to the inner wrapper. It costs nothing and saves you from mysterious debugging sessions where a function claims to be named wrapper.

#A Practical Logging Decorator

Let's put everything together in a decorator you might actually use — one that logs what function was called, with what arguments, and what it returned.

A decorator that automatically logs every call without touching the original function.
import functools

def log_calls(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        args_repr = [repr(a) for a in args]
        kwargs_repr = [f"{k}={v!r}" for k, v in kwargs.items()]
        signature = ", ".join(args_repr + kwargs_repr)
        print(f"Calling {func.__name__}({signature})")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned {result!r}")
        return result
    return wrapper

@log_calls
def power(base, exponent=2):
    return base ** exponent

power(3)
power(2, exponent=10)

#Where Decorators Appear in the Wild

You will meet decorators everywhere once you start reading Python code:

  • `@staticmethod` / `@classmethod` — Python built-ins that change how a method works in a class.
  • `@property` — turns a method into an attribute-style access.
  • Flask / Django routes@app.route("/home") registers a URL handler with zero boilerplate.
  • `@pytest.fixture` — marks a function as test setup in the pytest framework.
  • Caching@functools.lru_cache caches expensive results automatically.

Each of these is just a decorator following the same pattern you just learned.

@functools.lru_cache is a decorator from the standard library that caches return values.
import functools

@functools.lru_cache(maxsize=128)
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

print(fibonacci(35))
Quick check

What does the line `@my_decorator` above a function definition actually do?

Key takeaways

  • In Python, functions are **first-class objects** — they can be stored in variables, passed to functions, and returned from functions.
  • A decorator is a function that takes a function and returns a **new function** with added behavior wrapped around the original.
  • The `@decorator` syntax is just shorthand for `func = decorator(func)` — they are identical.
  • Use `*args` and `**kwargs` in your wrapper so your decorator works on **any function**, regardless of its parameters.
  • Always add `@functools.wraps(func)` to your inner wrapper to preserve the original function's name, docstring, and other metadata.
Practice challenges
Test yourself · earn XP
0/4
Predict the output#1

What does this code print?

predict-output
def shout(func):
    def wrapper():
        print(">>>")
        func()
        print("<<<")
    return wrapper

@shout
def greet():
    print("Hello!")

greet()
Fix the bug#2

This decorator is supposed to print the result of calling add(3, 4), but it always prints None instead. What is wrong?

fix-bug
import functools

def my_decorator(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
    return wrapper

@my_decorator
def add(a, b):
    return a + b

print(add(3, 4))
Fill in the blank#3

Complete the decorator so it prints 'Starting...' before the wrapped function runs, and works on functions with any number of arguments.

import functools

def announce(func):
    @functools.wraps(func)
    def wrapper():
        print("Starting...")
        return func()
    return wrapper

@announce
def add(a, b):
    return a + b

print(add(2, 3))
Reorder the lines#4

Put these lines in the right order to define a working decorator and apply it to a function.

1
def bold(func):
2
    return wrapper
3
    def wrapper():
4
        print("**")
5
        func()
6
        print("**", end="")
Your turn
Practice exercise

Write a decorator called require_positive that checks every argument passed to a function. If any argument is zero or negative, it should print an error message and return None instead of calling the function. If all arguments are positive, call the function normally and return its result. Test it on a divide(a, b) function.

Try it live — edit the code and hit Run to execute real Python:

solution.py · editable