New: 6 free SQL practice datasets with 300+ questions — try the SQL Compiler →
Python

Python Decorators Explained Step by Step

Python decorators explained step by step: closures, the @ syntax, wraps, decorators with arguments and lru_cache, with real code and output.

Upskly AI Team September 26, 2026 9 min read
Python Decorators Explained Step by Step

A Python decorator is a function that takes another function and returns a new, usually enhanced, version of it. You apply it with the @ symbol on the line above a function definition. Decorators let you add behaviour such as logging, timing, caching or permission checks to a function without touching its code.

The idea looks magical until you see the three small ideas behind it. This guide builds up to decorators step by step, with real output at each stage.

In this guide

The short version

  • @decorator above def f is just shorthand for f = decorator(f).
  • A decorator takes a function, defines an inner wrapper, and returns the wrapper.
  • Use *args, **kwargs so the wrapper accepts any call, and always return the result.
  • Add @functools.wraps(func) so the wrapped function keeps its name and docstring.

Two ideas you need first

1. Functions are objects

In Python you can pass a function to another function, like any value (see Python Functions):

def shout(text):
    return text.upper() + "!"

def apply(func, value):
    return func(value)

print(apply(shout, "hello"))

Output

HELLO!

2. Functions can be created inside functions

An inner function remembers the variables of the function that created it. This is called a closure. Here the inner multiply remembers n long after make_multiplier has returned:

def make_multiplier(n):
    def multiply(x):
        return x * n
    return multiply

double = make_multiplier(2)
print(double(5))

Output

10

A decorator is just these two ideas together: a function that receives a function, and builds and returns a new one.

Writing your first decorator

Here is loud. It takes a function, defines a wrapper that does something before and after calling it, and returns the wrapper. Done by hand, you replace the original function with the wrapped one:

def loud(func):
    def wrapper():
        print("before")
        func()
        print("after")
    return wrapper

def hello():
    print("hello")

hello = loud(hello)
hello()

Output

before
hello
after

The line hello = loud(hello) is the whole trick. Python gives it a shorthand: put @loud above the function, and it does exactly the same thing:

def loud(func):
    def wrapper():
        print("before")
        func()
        print("after")
    return wrapper

@loud
def hello():
    print("hello")

hello()

Output

before
hello
after

Handling arguments and return values

The wrapper above only works for functions with no arguments and no result. A general-purpose decorator accepts anything using *args, **kwargs (see Python *args and **kwargs Explained), passes it on, and returns whatever the original returned:

def logged(func):
    def wrapper(*args, **kwargs):
        print("calling", func.__name__, args, kwargs)
        result = func(*args, **kwargs)
        print("returned", result)
        return result
    return wrapper

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

total = add(2, b=3)
print(total)

Output

calling add (2,) {'b': 3}
returned 5
5

This is the standard shape of a decorator. Learn it once and you can write most of them.

Keeping the function’s name with functools.wraps

A decorated function is really the wrapper, so it loses its own identity. Its name and docstring now belong to wrapper, which confuses debugging tools and documentation:

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

@deco
def greet():
    """Say hello."""

print(greet.__name__, greet.__doc__)

Output

wrapper None

The fix is a decorator from the standard library. Add @wraps(func) above the wrapper and it copies the name, docstring and other details across:

from functools import wraps

def deco(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@deco
def greet():
    """Say hello."""

print(greet.__name__, greet.__doc__)

Output

greet Say hello.

Make it a habit: every decorator you write should use @wraps.

Decorators that take arguments

To write @repeat(3), you need one more layer. repeat(3) is called first and must return the real decorator, which then receives the function:

from functools import wraps

def repeat(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

@repeat(3)
def ping():
    print("ping")

ping()

Output

ping
ping
ping

Read it from the outside in: repeat(times) returns decorator, which takes func and returns wrapper. Three levels, one job each.

Practical examples

Counting calls

Functions are objects, so the wrapper can carry data of its own. This decorator counts how often the function is called:

from functools import wraps

def count_calls(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        wrapper.calls += 1
        return func(*args, **kwargs)
    wrapper.calls = 0
    return wrapper

@count_calls
def hi():
    return "hi"

hi()
hi()
hi()
print(hi.calls)

Output

3

Caching results

The standard library ships a ready-made decorator for caching: functools.lru_cache. It remembers results for arguments it has already seen. Recursive Fibonacci normally repeats a huge amount of work. With the cache, each value from 0 to 30 is computed exactly once, so the function body ran only 31 times:

from functools import lru_cache

calls = 0

@lru_cache(maxsize=None)
def fib(n):
    global calls
    calls += 1
    return n if n < 2 else fib(n - 1) + fib(n - 2)

print(fib(30))
print(calls)
print(fib.cache_info().hits > 0)

Output

832040
31
True

Other common uses: timing how long a function takes, retrying a flaky network call, checking permissions before running, and logging.

Stacking decorators

You can apply more than one. They are applied from the bottom up: the one closest to the function wraps it first, and the next wraps the result:

def bold(f):
    def w():
        return "<b>" + f() + "</b>"
    return w

def italic(f):
    def w():
        return "<i>" + f() + "</i>"
    return w

@bold
@italic
def text():
    return "hi"

print(text())

Output

<b><i>hi</i></b>

@italic ran first, giving <i>hi</i>, and then @bold wrapped that.

Decorators you already use

Python has several built in. @property turns a method into an attribute you read without brackets, @classmethod receives the class instead of an instance, and @staticmethod is a plain function that lives in a class:

class Circle:
    def __init__(self, r):
        self._r = r

    @property
    def area(self):
        return round(3.14159 * self._r ** 2, 2)

    @classmethod
    def unit(cls):
        return cls(1)

    @staticmethod
    def describe():
        return "a round shape"

c = Circle.unit()
print(c.area)
print(Circle.describe())

Output

3.14
a round shape

These belong to classes and objects, and decorators are also how many libraries add features, such as web frameworks registering routes.

Common mistakes

Mistake 1: forgetting to return the result

If the wrapper calls the function but does not return its value, every decorated function suddenly returns None:

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

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

print(add(1, 2))

Output

None

Mistake 2: forgetting to return the wrapper

If the decorator does not return wrapper, the decorated name becomes None and calling it fails:

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

@deco
def hi():
    return "hi"

try:
    hi()
except TypeError as e:
    print(type(e).__name__)
print(hi)

Output

TypeError
None

Mistake 3: leaving out the brackets on a decorator that takes arguments

@repeat and @repeat(3) are different things. Without the brackets, the function is passed in as times, and the call breaks later:

def repeat(times):
    def decorator(func):
        def wrapper(*args, **kwargs):
            for _ in range(times):
                func(*args, **kwargs)
        return wrapper
    return decorator

@repeat
def ping():
    print("ping")

try:
    ping()
except TypeError as e:
    print(type(e).__name__)

Output

TypeError

Mistake 4: forgetting @wraps

Everything still runs, but the function’s name and docstring are lost, which makes debugging harder.

Try it yourself

Work out each answer first, then open the solution.

1. Write a decorator shout that converts the returned string to upper case.

Show solution
def shout(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs).upper()
    return wrapper

@shout
def greet(name):
    return "hello " + name

print(greet("asha"))

Output

HELLO ASHA

The wrapper calls the function, changes the result, and returns it.

2. Write a decorator only_positive that raises ValueError when any argument is negative.

Show solution
def only_positive(func):
    def wrapper(*args):
        if any(a < 0 for a in args):
            raise ValueError("negative argument")
        return func(*args)
    return wrapper

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

print(add(2, 3))
try:
    add(2, -3)
except ValueError as e:
    print("Error:", e)

Output

5
Error: negative argument

3. Write a decorator tag(name) that wraps the returned text in HTML tags.

Show solution
def tag(name):
    def decorator(func):
        def wrapper():
            return f"<{name}>" + func() + f"</{name}>"
        return wrapper
    return decorator

@tag("p")
def hello():
    return "hi"

print(hello())

Output

<p>hi</p>

A decorator that takes an argument needs the extra outer function.

Run these in our free Python compiler.

Frequently asked questions

What is a decorator in Python?

A decorator is a function that takes another function, adds some behaviour around it, and returns the new version. You apply it with @name above the function definition.

What does the @ symbol do in Python?

It applies a decorator. Writing @deco above def f() is the same as writing f = deco(f) after the definition.

Why should I use functools.wraps?

Without it, the decorated function takes on the wrapper’s name and docstring. @wraps(func) copies the original’s details onto the wrapper so debugging and documentation still work.

How do I write a decorator that takes arguments?

Add an outer function that takes the arguments and returns the actual decorator: three nested functions in total, as in the repeat(times) example.

What are some common built-in decorators?

@property, @classmethod, @staticmethod, functools.lru_cache, functools.wraps and contextlib.contextmanager.

Can a class be used as a decorator?

Yes. A class that defines __call__ can wrap a function, which is handy when the decorator needs to keep state. A function-based decorator is usually simpler.

Run this code in your browser

The free Upskly compiler runs Python with nothing to install. Paste the example, change it and see what happens.

Open Python Compiler

Stuck on a traceback?

AI Assist works inside the Python notebook, so you can ask about an error or a concept without leaving the cell.

Try AI Assist

Test yourself

Timed questions on Decorators and Functional Tools, with an explanation for every answer.

Take the Quiz
Upskly AI Team
Learning made simple
Scroll to Top