Module 7 · Functional & Decorative Python ⏱ 19 min

Decorators: Functions Wrapping Functions

By the end of this lesson you will be able to:
  • Write a decorator that adds behaviour before and after a function call
  • Apply a decorator using @ syntax
  • Explain why decorators return a replacement function

You now know that functions can accept and return other functions. A decorator is the practical application of that idea: a function that takes a function as input, wraps it in some extra behaviour, and returns the wrapped version. The caller uses the original name but gets the enhanced behaviour for free.

Decorators solve cross-cutting concerns — logging, timing, access control, caching — without changing the function they wrap. Once you recognise the pattern, you will spot @login_required, @app.route, and @property everywhere in Python code. Writing your own decorators is a milestone that separates intermediate programmers from beginners.

flowchart LR
  A["original greet()"] --> D["decorator(wrapper)"]
  D --> B["wrapper runs pre-code"]
  B --> C["calls original greet()"]
  C --> E["wrapper runs post-code"]
  E --> F["returns result"]
  style D fill:#3776ab,color:#fff
A decorator receives the original function and returns a wrapper that runs extra code around it.

Manual decoration

Before learning the @ shortcut, it helps to see what happens by hand. A decorator is just a higher-order function. It receives the original function, defines an inner wrapper, and returns that wrapper. The wrapper calls the original function inside itself, sandwiched by whatever extra work the decorator needs to do.

In the example below, announce is the decorator. It prints a message before and after the function it wraps runs. greet = announce(greet) replaces the name greet with the wrapper, so every future call goes through the decorator. This explicit rebinding makes it clear that the original function still exists; it is simply being hidden behind a new name that points to the wrapper.

Decorating a function by hand, without @ syntax.
def announce(fn):
    def wrapper(name):
        print('About to run...')
        result = fn(name)
        print('Done running.')
        return result
    return wrapper

def greet(name):
    return f'Hello, {name}!'

greet = announce(greet)
print(greet('Ada'))

The @ syntax

Writing greet = announce(greet) after every function definition is repetitive and easy to forget. Python provides @decorator_name as syntactic sugar that does exactly the same thing, placed immediately above the def.

@announce before def greet(...) means define greet, then immediately pass it through announce and rebind the name. There is no magic — it is purely shorthand for the manual assignment you saw above. The decorated function is still a normal callable; it just happens to be the wrapper returned by the decorator. Understanding this equivalence is essential when you start stacking multiple decorators or debugging decorated code.

The same decorator applied with @ syntax.
def announce(fn):
    def wrapper(name):
        print('About to run...')
        result = fn(name)
        print('Done running.')
        return result
    return wrapper

@announce
def greet(name):
    return f'Hello, {name}!'

print(greet('Ada'))
flowchart TD
  A["@announce"] --> B["def greet(): ..."]
  B --> C["greet = announce(greet)"]
  style A fill:#3776ab,color:#fff
@decorator is shorthand for rebinding the function name through the decorator.

A decorator that modifies the return value

Decorators are not limited to side effects like printing. They can inspect, modify, or replace the return value entirely. The wrapper calls the original function, receives its result, and then decides what to hand back to the caller.

Below, uppercase is a decorator that converts whatever the wrapped function returns into an upper-case string. It does not care what the original function does; it only cares about the value coming back. This is a common pattern for normalisation, validation, and format conversion.

A decorator that transforms the return value before handing it back.
def uppercase(fn):
    def wrapper(*args):
        result = fn(*args)
        return str(result).upper()
    return wrapper

@uppercase
def greet(name):
    return f'Hello, {name}!'

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

print(greet('world'))
print(add(2, 3))

Preserving identity with functools.wraps

When you decorate a function, the original name gets rebound to the wrapper. That means greet.__name__ becomes 'wrapper' instead of 'greet', and the docstring disappears. For introspection tools, debuggers, and documentation generators, that is a problem.

The standard fix is functools.wraps, a decorator for your decorator. It copies metadata — name, docstring, module — from the original function onto the wrapper. It is not required for correctness, but omitting it is considered unpolished. Whenever you write a decorator that might be used by other developers, include @wraps(fn) above your wrapper definition.

Stacking decorators

You can apply more than one decorator to the same function. They nest from bottom to top: the decorator closest to the def runs first, and each subsequent decorator wraps the result of the one before it.

@bold
@italic
def title():
    return 'Hello'

This is equivalent to title = bold(italic(title)). The inner italic wraps the original title, then bold wraps the result. When reading stacked decorators, work from the inside out to understand the final behaviour.

flowchart TD
  A["original fn"] --> B["@italic"]
  B --> C["@bold"]
  C --> D["final wrapped fn"]
  style B fill:#3776ab,color:#fff
  style C fill:#3776ab,color:#fff
Stacked decorators nest from the inside out, each wrapping the result of the one below.
Exercise

Write a decorator double_result that doubles whatever the decorated function returns. The wrapper should use *args to forward arguments. Apply it manually (no @ syntax) in the tests by doing fn = double_result(fn).

def double_result(fn):
    # your wrapper here
    pass
Exercise

What does this print? Trace the order: wrapper runs first, then the original function, then the wrapper finishes.

def tag(fn):
    def wrapper(text):
        return '<p>' + fn(text) + '</p>'
    return wrapper

@tag
def echo(text):
    return text

print(echo('hi'))
Exercise

Write a decorator count_calls that counts how many times the decorated function is called. Use a closure variable calls. The wrapper should increment calls each invocation and then call the original function normally. Return calls from the wrapper as the result (ignore the original function's return value for this exercise).

def count_calls(fn):
    # use a closure variable
    pass
Exercise

This decorator is meant to return the original function's result doubled, but it forgets to return anything. Fix the wrapper so the decorated function's return value is preserved and doubled.

def double_result(fn):
    def wrapper(*args):
        fn(*args) * 2
    return wrapper
Exercise

Write a decorator require_positive that checks every positional argument passed to the decorated function. If any argument is less than or equal to zero, return the string 'all args must be positive' instead of calling the function. Otherwise call the function normally and return its result. Use *args.

def require_positive(fn):
    # your wrapper here
    pass

Recap

  • A decorator is a higher-order function that wraps another function to add behaviour.
  • @decorator above a def is shorthand for rebinding the name through the decorator.
  • The wrapper should accept *args (and **kwargs) so it works with any signature.
  • Use functools.wraps to preserve the original function's metadata.
  • Decorators are closures: the wrapper captures the original function in its enclosing scope.
  • Multiple decorators stack from the inside out, each wrapping the result of the one below it.

Next you will learn context managers, a pattern for running setup and teardown code reliably around a block — the with statement.

Checkpoint quiz

What does @announce placed above def greet(): do?

Why should a decorator wrapper use *args?

Go deeper — technical resources