Skip to content
intermediate

Python Decorators: Understand and Write a Simple Function Wrapper

You copy a snippet from a tutorial. It works. Then you notice a strange line sitting above the function you actually care about:

Published 2026-10-02Updated 2026-10-048 min read
Two backpackers waiting at a railway station, ready for their travel adventure.
Two backpackers waiting at a railway station, ready for their travel adventure. Photo by Ketut Subiyanto on Pexels.

You copy a snippet from a tutorial. It works. Then you notice a strange line sitting above the function you actually care about:

@timer
def clean_data(rows):
    ...

You delete the @timer line because you do not know what it does. The code still runs, but the timing output disappears. You just removed behavior you wanted, and you cannot explain why.

Here is the fix: that @ line is not magic. It is an assignment. Once you see the assignment, you can read any decorator, and you can write your own.

What the @ Line Actually Does

An original function flows into a decorator, which returns a wrapper; the function name is rebound to that wrapper, and calling the name runs the wrapper, which calls the original function.
The @decorator line is shorthand for passing a function to a decorator and rebinding its name to the returned wrapper.

A decorator is a function that takes a function and returns a replacement function. That is the whole idea. Everything else is syntax and convention.

You already know how to define functions and return values. This article adds one layer: returning a function instead of a number or a string. That is possible because functions in Python are objects. You can pass them around, store them in variables, and return them from other functions.

So when you write this:

@decorator
def greet():
    print("Hello")

Python reads it as this:

def greet():
    print("Hello")

greet = decorator(greet)

The @decorator line runs decorator(greet), takes whatever comes back, and rebinds the name greet to that result. The original function object still exists, but the name greet now points at the replacement.

That rebinding is the mechanism. The @ symbol is just shorthand for it.

Note: The name is rebound after the function definition runs. By the time decorator(greet) is called, greet is a normal function object, ready to be passed as an argument.

Knowledge check

Check your understanding

Answer this question before you continue.

What is the effect of placing `@decorator` immediately above a function named `greet`?
Single Choice

Focus: Translate decorator syntax into the equivalent function-name rebinding.

Build a Tiny Wrapper in 10 Lines

Let's build the smallest useful decorator. Save this as log_demo.py:

def log_call(func):
    def wrapper():
        print("Before the call")
        func()
        print("After the call")
    return wrapper


@log_call
def say_hello():
    print("Hello from inside the function")


say_hello()

Run it:

python log_demo.py

Expected output:

Before the call
Hello from inside the function
After the call

Read the code carefully. log_call takes func. Inside it, wrapper is defined but not called. The function returns wrapper itself. Then the @log_call line rebinds say_hello to that returned wrapper.

When you call say_hello(), you are actually calling wrapper(). Inside wrapper, func() calls the original function.

The names func and wrapper are conventions, not keywords. You could call them original and inner. Pick names that make the code readable and stay consistent.

Tip: If you ever get confused by the @ syntax, delete it and write the explicit form: say_hello = log_call(say_hello). The behavior is identical, and the mechanism becomes visible.

Knowledge check

Check your understanding

Answer this question before you continue.

Using the article's `log_call` wrapper and `say_hello` example, what is printed when `say_hello()` is called?
Output Prediction

Focus: Predict the order of output when a simple wrapper calls the original function between two messages.

Why the Wrapper Needs *args and **kwargs

The wrapper above works only for functions that take no arguments. The moment you decorate def add(a, b), it breaks.

Try it:

def log_call(func):
    def wrapper():
        print("Before the call")
        func()
        print("After the call")
    return wrapper


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


print(add(2, 3))

You get a traceback:

TypeError: wrapper() takes 0 positional arguments but 2 were given

The error is honest. wrapper accepts zero arguments, but add(2, 3) passes two. The wrapper is the function being called, so it must accept whatever the caller sends.

The fix is to forward any arguments through:

def log_call(func):
    def wrapper(*args, **kwargs):
        print("Before the call")
        result = func(*args, **kwargs)
        print("After the call")
        return result
    return wrapper


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


print(add(2, 3))

Expected output:

Before the call
After the call
5

Two things changed. First, *args, **kwargs lets wrapper accept any signature. Second, return result preserves the original return value.

Common mistake: Forgetting return inside the wrapper. Without it, the decorated function silently returns None. No error, no traceback, just a wrong result that shows up three functions later. If a decorated function suddenly returns None, check the wrapper first.

Knowledge check

Check your understanding

Answer this question before you continue.

A wrapper around `add(a, b)` currently takes no parameters and calls `func()` without returning anything. Which change addresses both the argument error and the lost result?
Debugging

Focus: Identify the wrapper changes needed to accept arbitrary call arguments and preserve a wrapped function's result.

A Practical Wrapper: Timing a Function

Now let's build something you would actually keep in a project. Timing a slow step is a real use case: you want to know how long a data cleanup or a file parse takes, without editing the function body.

Save this as timing_demo.py:

import time
import functools


def timer(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} took {elapsed:.4f} seconds")
        return result
    return wrapper


@timer
def slow_sum(n):
    total = 0
    for i in range(n):
        total += i
    return total


print(slow_sum(1_000_000))

Run it:

python timing_demo.py

Expected output (the timing value will vary):

slow_sum took 0.0312 seconds
499999500000

The decorator wraps slow_sum with a stopwatch. The function body is untouched. If you later want to time a different function, you add @timer above it and move on.

The functools.wraps(func) line is worth understanding. Without it, the wrapper replaces the original function's identity. Try this:

def timer(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper


@timer
def my_func():
    """Does something useful."""
    pass


print(my_func.__name__)
print(my_func.__doc__)

Expected output:

wrapper
None

The name and docstring are gone. They now belong to wrapper. That breaks debugging tools, help output, and anything that inspects function metadata.

Add @functools.wraps(func) above the inner wrapper, and the output becomes:

my_func
Does something useful.

functools.wraps copies the original function's metadata onto the wrapper. It is a one-line fix that prevents a class of confusing bugs. Use it in every decorator you write.

Knowledge check

Check your understanding

Answer this question before you continue.

What problem does `@functools.wraps(func)` address in the article's decorator example?
Misconception Check

Focus: Explain what `functools.wraps` preserves when used on a decorator's inner wrapper.

Stacking Decorators and the Order Trap

You can apply more than one decorator to the same function:

@log_call
@timer
def process():
    ...

The order matters. Decorators apply bottom-up: the one closest to def runs first.

Here is the mapping:

Written orderExecution orderEquivalent assignment
@log_call then @timertimer wraps first, then log_call wraps the resultprocess = log_call(timer(process))
@timer then @log_calllog_call wraps first, then timer wraps the resultprocess = timer(log_call(process))

Read the equivalent assignment from the inside out. The innermost call happens first. That is the decorator closest to def.

If you swap the order, the output changes. log_call prints before and after the call. timer measures the call. If log_call is on top, its "Before" and "After" prints are outside the timer, so they are not measured. If timer is on top, the log prints are inside the timed region.

Tip: When you are unsure about order, write the explicit assignment form. process = log_call(timer(process)) makes the nesting visible.

When a Plain Function Is the Better Choice

Decorators are not free. They add a layer between the caller and the function body. That layer is worth it when the same before/after behavior must apply to many functions. It is a cost when the behavior is used once.

Use a decorator when:

  • The same wrapper logic applies to several functions (logging, timing, retrying, caching).
  • The call site should stay clean and the wrapper is genuinely reusable.
  • The behavior is orthogonal to the function's job, not part of it.

Skip the decorator when:

  • The behavior is used once. A plain call inside the function is easier to read.
  • The wrapper hides control flow that the reader needs to see.
  • You are still learning the mechanism. Write the explicit func = decorator(func) form first, then convert to @ once it feels obvious.

There is also a debugging cost. When a decorated function raises an exception, the traceback points into wrapper, not the function you wrote. functools.wraps helps with metadata, but the stack frame is still the wrapper's. For a small project this is fine. For a function you are actively debugging, temporarily remove the decorator and test the raw function.

Warning: Do not decorate everything you touch. A decorator that is used once is usually a function call wearing a costume. Reach for it when the pattern repeats.

Practice: Write Your Own Wrapper

Here is a focused drill. Write a @count_calls decorator that prints how many times a function has been called.

Starter code:

import functools


def count_calls(func):
    # Your code here
    pass


@count_calls
def greet(name):
    print(f"Hello, {name}")


greet("Ana")
greet("Bo")
greet("Cy")

Expected behavior:

greet has been called 1 times
Hello, Ana
greet has been called 2 times
Hello, Bo
greet has been called 3 times
Hello, Cy

Hint: store the counter as an attribute on the wrapper function, or use a list or dictionary in the enclosing scope. The counter must survive between calls, so it cannot be a local variable inside wrapper.

Once it works, add @functools.wraps(func) above wrapper and confirm that greet.__name__ still returns "greet".

Where to Go Next

Read @name as func = name(func). That single line of translation covers almost every decorator you will meet.

Reach for a decorator when the same wrapper behavior must apply across several functions and the call site should stay clean. Otherwise, keep the plain function. A wrapper that is used once is usually just a function call in disguise.

Your next step: build a small decorators.py module with timer, log_call, and count_calls. Import it into a script and apply each one to a different function. That module becomes a reusable asset you can drop into any project, and writing it forces you to internalize the mechanism instead of copying snippets you do not understand.

Knowledge check

Final check

Finish the article by checking the ideas you just learned.

For `@log_call` followed by `@timer` above `def process():`, which assignment is equivalent to the decorated definition?
Question 1 of 2Single Choice

Focus: Determine the nested assignment produced by stacked decorators in written order.

A project needs the same logging behavior on several functions, and logging is separate from each function's main job. Which choice best matches the article's guidance?
Question 2 of 2Misconception Check

Focus: Choose a decorator when shared, reusable behavior is separate from the decorated functions' main jobs.

References

  1. PEP 318 – Decorators for Functions and Methods | peps.python.orgpeps.python.org
Practical resource

Want a more structured Python path?

Use the Python Starter Pack to turn scattered tutorials into a focused practice path.

View the bundle
Coming soon

Python for Artificial Intelligence Starter Pack

Build a Python foundation you can actually use. The Python for AI Starter Pack brings together a guided path through setup, core programming concepts, data structures, files, JSON, APIs, debugging, and practical projects—so you can move quickly from running your first program to understanding and building useful software.

$9
PDF BundlePythonAIBeginner
  • 264-page illustrated PDF
  • 12 guided Python chapters
  • Visual concept diagrams
  • Self-assessment quizzes
  • Bonus deep-dive sections
  • Files, JSON, APIs, debugging & projects
  • Foundation for data, automation & AI

Coming soon

Free Python bundle

Get the LearnPyFast Python for Artificial Intelligence Starter Bundle

Build a Python foundation you can actually use. The Python for Artificial Intelligence Starter Pack brings together a guided path through setup, core programming concepts, data structures, files, JSON, APIs, debugging, and practical projects—so you can move quickly from running your first program to understanding and building useful software.

You’ll receive the bundle by email. You can unsubscribe anytime.

No spam. You can unsubscribe anytime. See our Privacy policy.

Related sites

Continue beyond Python

Explore related Worldmonger sites when you want to move from Python basics into JavaScript or LLM application building.

JavaScript tutorialstutorial

LearnJSFast

Beginner-friendly JavaScript tutorials for practical web development and self-taught developers.

JavaScriptFrontendWeb development
Visit LearnJSFast
LLM tutorialstutorial

LearnLLMFast

Practical LLM tutorials for builders who want to understand prompting, workflows, agents, and AI applications.

LLMAIBuilders
Visit LearnLLMFast

Keep learning

Related tutorials

Continue with nearby Python topics and beginner-friendly explanations.

A breathtaking sunrise over a vast mountainous landscape with clear skies.
beginner
6 min read

Defining Functions in Python

A function turns a block of code into a named tool you can call by name. Write the steps once, give them a name, and reuse them across your program instead…

Read tutorial