Python Decorators Explained: A Step-by-Step Guide with Examples
A Python decorator is a function that takes another function and returns a new function that adds behaviour before or after the original one runs. Writing @log_calls above def greet is exactly the same as writing greet = log_calls(greet) right after the definition:
def log_calls(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@log_calls
def greet(name):
return f"Hello, {name}"
print(greet("Asha"))
# Calling greet
# Hello, Asha
That is the whole idea. The rest of this guide explains why it works, builds a decorator from scratch, and covers the details interviewers like to ask about: functools.wraps, decorators that take arguments, and the built-in decorators you already use.
Step 1: functions are objects
In Python, a function is an ordinary object. You can assign it to another name, pass it to another function, and return it from a function. Decorators depend on all three.
def shout(text):
return text.upper() + "!"
yell = shout # no brackets: we are not calling it
print(yell("hello")) # HELLO!
print(shout.__name__) # shout
def apply(func, value):
return func(value)
print(apply(shout, "placement season")) # PLACEMENT SEASON!
The key detail is the missing brackets. shout is the function object; shout("hi") is the result of calling it. Mixing these two up is the most common reason a first decorator does not work.
Step 2: closures remember their surroundings
A function defined inside another function can use the outer function's variables, and it keeps access to them even after the outer function has returned. That inner function is called a closure.
def make_multiplier(n):
def multiply(x):
return x * n
return multiply
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(5), triple(5)) # 10 15
print(double.__closure__[0].cell_contents) # 2
make_multiplier(2) has finished running, yet double still remembers n = 2. A decorator uses the same trick: the inner wrapper function remembers func, the function it is wrapping. The closures lesson goes through this with graded exercises.
Step 3: write a decorator by hand
Put the two ideas together. A decorator takes a function, defines a wrapper that calls it, and returns the wrapper. First, without the @ syntax:
def announce(func):
def wrapper():
print("Before the call")
func()
print("After the call")
return wrapper
def say_hi():
print("Hi!")
say_hi = announce(say_hi)
say_hi()
# Before the call
# Hi!
# After the call
The line say_hi = announce(say_hi) replaces the original function with the wrapper. The @ symbol is shorthand for exactly that line. Writing @announce directly above def say_hi(): does the same thing, just without repeating the name three times.
This version of announce only works for functions with no arguments and throws away the return value. A real decorator should accept any arguments and return whatever the original function returns. That is why the first example used *args, **kwargs and return func(*args, **kwargs). Forgetting that return is a classic bug: the decorated function silently starts returning None.
Step 4: use functools.wraps (and why it matters)
A plain wrapper hides the identity of the original function. Its name becomes wrapper and its docstring disappears. That breaks help(), logging, debugging tools and any framework that looks functions up by name.
import functools
def plain(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
def wrapped(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@plain
def add(a, b):
"""Return the sum of a and b."""
return a + b
@wrapped
def subtract(a, b):
"""Return a minus b."""
return a - b
print(add.__name__, add.__doc__) # wrapper None
print(subtract.__name__, subtract.__doc__) # subtract Return a minus b.
print(subtract.__wrapped__(10, 4)) # 6
functools.wraps copies the name, docstring, module and other metadata from the original function onto the wrapper, and stores the original in __wrapped__. Add it to every decorator you write. It costs one line.
Decorators that take arguments
Sometimes you want to configure a decorator, as in @repeat(times=3). That needs one more layer: a function that takes the arguments and returns the actual decorator.
import functools
def repeat(times):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = None
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(times=3)
def cheer(team):
print(f"Go {team}!")
cheer("India")
# Go India!
# Go India!
# Go India!
Read @repeat(times=3) in two steps. First Python calls repeat(times=3), which returns decorator. Then it applies that to the function: cheer = decorator(cheer). Three layers, each with one job: take the settings, take the function, run the call.
A practical decorator: timing a function
Measuring how long a function takes is a common real use. time.perf_counter() is the right clock for this, and try/finally makes sure the time is printed even if the function raises an error.
import functools
import time
def timer(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
try:
return func(*args, **kwargs)
finally:
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.1f}s")
return wrapper
@timer
def slow_sum(n):
time.sleep(0.2)
return sum(range(n))
print(slow_sum(1_000_000))
# slow_sum took 0.2s
# 499999500000
The timing line prints before the result because the wrapper finishes (and runs its finally block) before print receives the return value.
A practical decorator: retrying on failure
Network calls fail for temporary reasons. A retry decorator keeps that logic out of your business code. Note that it only retries the exceptions you name, and re-raises the last error instead of hiding it.
import functools
import time
def retry(times=3, exceptions=(Exception,), delay=0.0):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except exceptions as exc:
print(f"Attempt {attempt} failed: {exc}")
if attempt == times:
raise
time.sleep(delay)
return wrapper
return decorator
calls = {"count": 0}
@retry(times=3, exceptions=(ConnectionError,))
def fetch_marks():
calls["count"] += 1
if calls["count"] < 3:
raise ConnectionError("server busy")
return {"maths": 91, "physics": 84}
print(fetch_marks())
# Attempt 1 failed: server busy
# Attempt 2 failed: server busy
# {'maths': 91, 'physics': 84}
In real code you would pass a small delay and usually increase it on each attempt. Retrying on every Exception is a mistake: a TypeError from a bug will not fix itself on the third try.
Stacking decorators
You can apply more than one decorator. The one closest to the function is applied first, so the order matters.
import functools
def bold(func):
@functools.wraps(func)
def wrapper():
return "<b>" + func() + "</b>"
return wrapper
def italic(func):
@functools.wraps(func)
def wrapper():
return "<i>" + func() + "</i>"
return wrapper
@bold
@italic
def label():
return "Python"
print(label()) # <b><i>Python</i></b>
This is the same as label = bold(italic(label)).
Built-in decorators you already use
Python ships with several decorators. Once you know how decorators work, these stop looking like magic.
@propertyturns a method into an attribute that is computed on access.@staticmethoddefines a method that needs neither the instance nor the class.@classmethodreceives the class ascls, which makes it ideal for alternative constructors.@dataclass(fromdataclasses) is a class decorator: it reads the annotated fields and generates__init__,__repr__and__eq__for you.
from dataclasses import dataclass
@dataclass
class Student:
name: str
marks: list[int]
@property
def average(self):
return sum(self.marks) / len(self.marks)
@staticmethod
def is_pass(score):
return score >= 40
@classmethod
def from_csv(cls, line):
name, *scores = line.split(",")
return cls(name, [int(s) for s in scores])
s = Student.from_csv("Ravi,78,85,92")
print(s) # Student(name='Ravi', marks=[78, 85, 92])
print(s.average) # 85.0
print(Student.is_pass(35)) # False
@functools.lru_cache is another built-in decorator. It remembers the results of previous calls, so a recursive function with repeated inputs becomes fast:
from functools import lru_cache
@lru_cache(maxsize=None)
def ways_to_climb(n):
if n < 2:
return 1
return ways_to_climb(n - 1) + ways_to_climb(n - 2)
print(ways_to_climb(50)) # 20365011074
print(ways_to_climb.cache_info()) # CacheInfo(hits=48, misses=51, maxsize=None, currsize=51)
The full story, including maxsize and the unhashable-argument trap, is in Python lru_cache explained.
Common mistakes with decorators
- Forgetting to return the wrapper. If the decorator has no
return wrapper, the decorated name becomesNoneand calling it raisesTypeError: 'NoneType' object is not callable. - Forgetting to return the result. The wrapper calls
func(...)but does not return its value, so the function starts returningNone. - Adding brackets to a decorator without arguments.
@timer()callstimerwith no function and raisesTypeError: timer() missing 1 required positional argument: 'func'. Brackets are only for decorators built with the extra layer, like@repeat(times=3). - Skipping
functools.wraps. Everything still runs, but tracebacks, logs andhelp()showwrapperinstead of the real name. - Doing heavy work at decoration time. Code in the decorator body (outside the wrapper) runs once when the module is imported, not on every call.
Practise decorators in the browser
Decorators click when you write a few yourself. The functions lesson covers first-class functions, and the decorators lesson has runnable examples and graded exercises that check your wrapper, not just your output. Python runs in the browser with no install, so you can paste any example above into the Python terminal or the practice editor and change it line by line.