Quick Answer

A Python decorator is a function that takes another function, wraps it in extra behaviour, and returns the wrapped version, so you can add things like timing or logging without editing the original code. The @ syntax is just a shortcut: writing @timer above a function means the same as func = timer(func). Use functools.wraps to keep the original function's name and docstring, and add one more layer of nesting when your decorator needs its own arguments.

What Are Python Decorators?

If you have written a few Python functions and now keep copy-pasting the same "extra" code (a log line here, a timer there), python decorators are the tool you are looking for. A decorator is a function that takes another function, adds some behaviour around it, and hands back a new function you can call in its place.

The key word is wrap. You wrap your original function so that every time it runs, your extra code runs too, without you having to edit the function itself. In this tutorial we build up the idea slowly: from functions-as-objects, to closures, to the @ syntax, then real examples with timing and logging.

New to Python or want the full picture? Our free Python course covers functions, closures, and decorators step by step.

Functions Are Just Objects in Python

Before decorators make sense, you need one idea: in Python, functions are objects, just like numbers or strings. You can store a function in a variable, pass it to another function, or return it from one.

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

# Assign the function itself (no parentheses) to a new name
say_hello = greet
print(say_hello("Riya"))   # Hello, Riya!

Notice we wrote greet, not greet(). With parentheses you call the function; without them you refer to the function object itself. That small difference is the whole basis of decorators.

Nested Functions and Closures

The second idea is that you can define a function inside another function, and the inner one can still see the outer one's variables even after the outer function has finished. That remembered inner function is called a closure.

def outer():
    message = "Hi from inside"
    def inner():
        print(message)      # inner can see outer's variables
    return inner            # return the function, don't call it

my_func = outer()
my_func()                   # Hi from inside

Put the two ideas together, a function that takes a function and returns a new function, and you have already written a decorator, even if it does not look like one yet.

def shout(func):
    def wrapper():
        return func().upper()   # call the original, then modify its result
    return wrapper

def greet():
    return "hello"

greet = shout(greet)
print(greet())              # HELLO

The @ Syntax: Sugar for Wrapping

Writing greet = shout(greet) by hand works, but Python gives you a cleaner shortcut: the @ symbol. Putting @shout on the line above a function definition does exactly the same thing.

def shout(func):
    def wrapper():
        return func().upper()
    return wrapper

@shout
def greet():
    return "hello"

print(greet())              # HELLO

So @shout is pure syntactic sugar, a nicer way to write greet = shout(greet). There is no hidden magic; it is the same wrapping you did by hand, just easier to read.

One problem: our wrapper takes no arguments, so it breaks if the decorated function needs some. The fix is to accept *args and **kwargs and pass them straight through, which brings us to a real example.

A Real Decorator: Timing and Logging

Here is a decorator you will actually reuse: one that times how long a function takes and logs it. It accepts any arguments by using *args and **kwargs.

import functools
import time

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

@timer
def slow_add(a, b):
    time.sleep(0.5)
    return a + b

print(slow_add(2, 3))
# slow_add took 0.5001s
# 5

The pattern is always the same: record something before the call, run func(*args, **kwargs), then record something after and return the result. Forgetting that last return is the most common decorator bug: your function suddenly returns None.

Keep Metadata with @functools.wraps

Did you notice @functools.wraps(func) above? Without it, wrapping quietly destroys your function's identity. Watch what happens:

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

@timer
def slow_add(a, b):
    """Add two numbers."""
    return a + b

print(slow_add.__name__)    # wrapper   (wrong!)
print(slow_add.__doc__)     # None      (docstring lost)

Because slow_add now points at wrapper, its name and docstring are gone. This breaks documentation tools, debuggers, and help(). Adding @functools.wraps(func) copies the original name, docstring, and other metadata onto the wrapper.

BehaviourWithout @wrapsWith @wraps
Keeps original nameNoYes
Keeps docstringNoYes
Works with help() and docs toolsNoYes

Recommendation: always add @functools.wraps(func) to your wrapper. It costs one line and saves confusing bugs.

Decorators That Take Arguments

Sometimes you want to configure a decorator, like @repeat(times=3). That needs one more layer: a function that takes the argument and returns a decorator. Three nested functions in total.

import functools

def repeat(times):                     # 1. takes the argument
    def decorator(func):               # 2. the real decorator
        @functools.wraps(func)
        def wrapper(*args, **kwargs):  # 3. wraps the call
            for _ in range(times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(times=3)
def greet(name):
    print(f"Hi, {name}!")

greet("Arjun")
# Hi, Arjun!
# Hi, Arjun!
# Hi, Arjun!

Read it from the outside in: repeat(times=3) runs first and returns decorator; then decorator wraps greet exactly like before. The extra layer exists only to remember the times value in a closure.

Gotchas and When to Use Decorators

Decorators are powerful, but a few gotchas trip up beginners:

  • Always return the result. If your wrapper forgets return, the decorated function returns None.
  • Use *args and **kwargs so your wrapper works with any function signature.
  • Order matters when you stack them. Decorators apply from the bottom up: the one closest to the function runs first.
@bold
@italic
def text():
    return "hi"

# Same as: text = bold(italic(text))
# italic wraps first, then bold wraps that

Use a decorator when the same extra behaviour (logging, timing, access checks, caching, retries) needs to apply to many functions. If it is a genuine one-off, a plain function call is simpler and clearer. Want to practise these patterns with guided exercises? The free Priodemy Python track takes you from the basics all the way to decorators and beyond.

Frequently Asked Questions

What exactly is a Python decorator?

A Python decorator is a function that takes another function as input, adds some behaviour around it, and returns a new function. You apply it by writing @decorator_name on the line above a function definition, which is just a shortcut for my_func = decorator_name(my_func).

Do I always need @functools.wraps?

Almost always, yes. Without it, the wrapped function loses its original name and docstring, which breaks help(), debuggers, and documentation tools. It is one extra line at the top of your wrapper, and there is rarely a good reason to skip it.

How do decorators that take arguments work?

You add one more layer of nesting. The outer function takes the argument (like times=3) and returns a normal decorator, which then wraps your function. So @repeat(times=3) first calls repeat(3), and the decorator it returns does the actual wrapping.

Do decorators make my code slower?

Only very slightly. The wrapper adds one extra function call each time you run the decorated function, which is negligible for almost all programs. The decoration itself happens just once, when the function is defined, not on every call.

Can I put more than one decorator on a function?

Yes. Stacking decorators is common, but order matters, because they apply from the bottom up. With @a written above @b, Python runs a(b(func)), so the decorator nearest the function wraps it first.