🐍 Python Course · Stage 1 · Lesson 13 of 89 · Decorators
Course Overview →

🟩 Start with the Beginner tab. Read it fully before moving on.

← Closures Generators →
thecodex.expert · The Codex Family of Knowledge
Python

Decorators in Python

A decorator is a function that wraps another function to extend or modify its behaviour without changing its source code — the mechanism behind @staticmethod, @property, @dataclass, Flask routes, and pytest fixtures.

Python 3.13 docs.python.org Last verified:
Canonical Definition

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.

🟩 Beginner

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.

⏱ 35 min📄 2 sections🔶 Prerequisite: Closures
The idea

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.

Pythondecorator_basics.py
# 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.

Pythondecorator_timer.py
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

Continue to Generators →

🔵 Intermediate

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.

⏱ 35 min📄 2 sections🔶 Prerequisite: Beginner tab
💡

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.

Pythondecorator_stacking.py
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__.

Pythonclass_decorators.py
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
Commonly confused
Always use @functools.wraps(func) in your wrapper. Without it, the decorated function loses its name, docstring, and signature. This breaks help(), logging, error messages, and any tool that uses function introspection. It takes one line and should be considered mandatory.
Decorators run at definition time, not call time. When Python encounters @decorator above a def, it runs the decorator immediately — at import time. The wrapper runs later when the function is called. Side effects in the decorator body (like print statements) happen at import time.

✅ 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__

Continue to Generators →

🔴 Expert

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.

⏱ 25 min📄 1 section🔶 Prerequisite: After Stage 2

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.

Pythondescriptor_decorator.py
# @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 function

Understanding 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

Continue to Generators →

Sources

1
Python Language Reference §8.7 — Function definitions: decorator syntax. docs.python.org/3/reference/compound_stmts.html#function-definitions.
2
Python Standard Library — functools.wraps. docs.python.org/3/library/functools.html#functools.wraps.
3
Python Reference — Descriptor HowTo Guide. docs.python.org/3/howto/descriptor.html.
4
PEP 318 — Decorators for Functions and Methods. peps.python.org/pep-0318/.
Source confidence: High Last verified: Primary source: Python Language Reference §8.7