A decorator in Python is a callable that takes a function (or class) as its argument and returns a modified version of it. The @syntax is syntactic sugar: @decorator above a def is exactly equivalent to func = decorator(func) immediately after the def.
Modifying functions without changing them
What you'll learn: What a decorator is, that @decorator is exactly decorator(func), and how to write a simple wrapper decorator. After this, @staticmethod, @property, @dataclass, and library decorators will no longer feel like magic.
How to read this tab: After reading the first code block, cover the @shout version and write it yourself using the manual shout(greet) form. Then uncover and compare. Internalising the equivalence is the whole lesson.
A decorator modifies a function the same way a coffee shop "modifies" a coffee order — you add milk, sugar, or flavour without changing what coffee fundamentally is. @decorator is simply a clean way of writing func = decorator(func).
@decorator is syntax, not magic. It's exactly func = decorator(func). Every time you see @something above a def, replace it mentally with "the function below gets passed to something, and the result replaces the function." Once this equivalence is automatic, every decorator — no matter how complex — becomes readable.
What a decorator is
The Python Language Reference specifies that the @expression syntax above a function definition is syntactic sugar. It evaluates the expression to get a callable, passes the function to it, and rebinds the function name to the result. Nothing more — no magic, just a function that wraps another function.
# WITHOUT decorator syntax
def shout(func):
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return result.upper()
return wrapper
def greet(name):
return f"hello, {name}"
greet = shout(greet) # manually wrap
print(greet("Priya")) # HELLO, PRIYA
# WITH decorator syntax — identical result
def shout(func):
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return result.upper()
return wrapper
@shout # exactly the same as: greet = shout(greet)
def greet(name):
return f"hello, {name}"
print(greet("Priya")) # HELLO, PRIYA@functools.wraps(func) is not optional — it's required practice. Without it: func.__name__ returns "wrapper", func.__doc__ returns the wrapper's docstring, and inspect.signature() returns the wrapper's signature. Tools that use introspection (logging, documentation generators, Flask, pytest) will all misbehave. It's one line. Always include it.
Writing a useful decorator
A well-written decorator uses functools.wraps to preserve the original function's metadata — name, docstring, and signature — which is critical for debuggability and tools that inspect function metadata.
import time
import functools
def timer(func):
"""Decorator that prints how long the function takes."""
@functools.wraps(func) # preserves func.__name__, __doc__, etc.
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.4f}s")
return result
return wrapper
@timer
def slow_sum(n):
"""Add numbers from 1 to n."""
return sum(range(n))
print(slow_sum(10_000_000)) # slow_sum took 0.3124s
# 49999995000000
# Without @functools.wraps, slow_sum.__name__ would be 'wrapper'
print(slow_sum.__name__) # 'slow_sum' ← preserved correctly✅ Beginner tab complete
- I know @decorator is EXACTLY equivalent to func = decorator(func)
- I always use @functools.wraps(func) inside my wrapper functions
- I can write a decorator that adds timing, logging, or validation to any function
- I know decorators execute at definition time, not call time
Decorator factories, stacking, and class decorators
What you'll learn: Decorators that accept arguments (decorator factories — three levels of nesting). Stacking multiple decorators. Class decorators — including how @dataclass works.
How to read this tab: Write @repeat(3) yourself from scratch before looking at the solution. The three-level nesting (function → decorator → wrapper) is the pattern that trips people up — writing it forces the structure to click.
The three-layer pattern for argument decorators: outermost function takes arguments → middle function takes func → innermost (wrapper) does the actual work. def repeat(times): → def decorator(func): → def wrapper(*args, **kwargs):. Each level has a job: times configures, decorator wraps, wrapper executes.
Stacking decorators and decorators with arguments
Multiple decorators can be stacked — they apply from bottom to top (the one closest to the function definition applies first). A decorator with arguments is a function that returns a decorator, adding one more layer of nesting.
import functools
# Stacking — applied bottom-up
@decorator_a
@decorator_b
def func(): ...
# equivalent to: func = decorator_a(decorator_b(func))
# Decorator WITH arguments — requires an extra layer
def repeat(times):
"""Decorator factory — takes an argument, returns a decorator."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(3) # repeat(3) returns a decorator, which wraps greet
def greet(name):
print(f"Hello, {name}!")
greet("Priya")
# Hello, Priya!
# Hello, Priya!
# Hello, Priya!
# Practical decorator with arguments: retry on failure
def retry(max_attempts=3, delay=1.0):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
import time
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == max_attempts:
raise
print(f"Attempt {attempt} failed: {e}. Retrying...")
time.sleep(delay)
return wrapper
return decorator
@retry(max_attempts=3, delay=0.5)
def fetch_data(url):
import urllib.request
return urllib.request.urlopen(url).read()@dataclass inspects __annotations__ to generate methods. This is the canonical example of a class decorator doing real work: it reads the class body's type annotations and auto-generates __init__, __repr__, __eq__, and optionally __lt__, __hash__, and more. Understanding this makes the entire dataclasses module obvious rather than magical.
Class decorators
Decorators work on classes too — the same @syntax applies, and the class is passed to the decorator. Python's built-in @dataclass is the most widely used class decorator; it inspects the class's annotations and auto-generates __init__, __repr__, and __eq__.
from dataclasses import dataclass
# @dataclass is a class decorator — Python's most used
@dataclass
class Point:
x: float
y: float
p = Point(1.0, 2.0)
print(p) # Point(x=1.0, y=2.0) ← generated __repr__
print(p == Point(1.0, 2.0)) # True ← generated __eq__
# Writing your own class decorator
def singleton(cls):
"""Ensure only one instance of cls can exist."""
instances = {}
@functools.wraps(cls)
def get_instance(*args, **kwargs):
if cls not in instances:
instances[cls] = cls(*args, **kwargs)
return instances[cls]
return get_instance
@singleton
class DatabaseConnection:
def __init__(self, url):
self.url = url
db1 = DatabaseConnection("postgresql://...")
db2 = DatabaseConnection("postgresql://...")
print(db1 is db2) # True — same instance✅ Intermediate tab complete
- I can write a decorator factory: @repeat(3) requires def repeat(times): def decorator(func): def wrapper(...)...
- I know decorators stack bottom-up: @a @b def f is a(b(f))
- I can write a class decorator (takes a class, returns modified class)
- I understand that @dataclass is just a class decorator that generates __init__, __repr__, __eq__
Descriptors and the decorator protocol
What you'll learn: How @staticmethod, @classmethod, and @property are implemented as descriptors — objects with __get__ — not as ordinary closures. The descriptor protocol that powers Python's attribute lookup system.
How to read this tab: Implement a simplified version of @property as a class with __get__. This demystifies one of Python's most important built-in decorators.
The descriptor protocol and method decorators
Python's built-in method decorators — @staticmethod, @classmethod, and @property — are implemented as descriptors, not as ordinary closures. A descriptor is an object that defines __get__ (and optionally __set__, __delete__). When an attribute is accessed on a class or instance, Python calls the descriptor's __get__ method. This is how @property turns a method into an attribute — the property object's __get__ calls the underlying function transparently.
# @property is a descriptor — here's a simplified version
class property_like:
def __init__(self, fget):
self.fget = fget
def __get__(self, obj, objtype=None):
if obj is None:
return self # class access — return descriptor itself
return self.fget(obj) # instance access — call the getter
class Circle:
def __init__(self, radius):
self.radius = radius
@property_like # equivalent to built-in @property
def area(self):
import math
return math.pi * self.radius ** 2
c = Circle(5)
print(c.area) # 78.539... — __get__ called the fget functionUnderstanding the descriptor protocol explains why @staticmethod returns a function regardless of whether it's accessed on the class or an instance, while @classmethod always passes cls as the first argument — these are implemented as distinct descriptor types (staticmethod and classmethod objects), not as closures.
✅ Expert tab complete
- I know @property is a descriptor object, not a simple closure
- I can implement a basic descriptor with __get__
- I understand how __get__(self, obj, objtype) receives both the instance and the class