I avoided Python decorators for years. Every time I saw @something above a function, my brain did a little shutdown ritual. The syntax looked like magic. The explanations I found were either “it’s just a function that takes a function and returns a function” (technically correct, completely useless) or they jumped straight to building a Flask app without ever explaining what the @ actually does.

Python programming code on screen with matplotlib visualization - decorators tutorial featured image
Image: MikeRun via Wikimedia Commons (CC BY-SA 4.0)

Then one afternoon I was debugging a codebase where every function started with the same logging boilerplate — ten identical lines of logger.info(f"Entering {func_name}") and try/except blocks copy-pasted across 40 functions. I wanted to scream. And that’s when decorators finally made sense.

Here’s what I wish someone had told me: a decorator is just a function that takes another function, adds some behavior around it, and hands it back. That’s it. The @ syntax is just sugar. Once you see the pattern without the sugar, everything else falls into place.

Your First Decorator: A Function Timer

Let’s start with the most practical decorator you’ll ever write — one that measures how long a function takes to run:

import time

def timer(func):
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        elapsed = time.time() - start
        print(f"{func.__name__} took {elapsed:.4f}s")
        return result
    return wrapper

@timer
def process_data():
    time.sleep(0.5)
    return "done"

result = process_data()
# Output: process_data took 0.5001s

Here’s what’s happening, line by line. timer is a regular function that accepts func as its argument. Inside, it defines a wrapper function that does the actual work: it records the start time, calls the original function, records the end time, prints the elapsed time, and returns whatever the original function returned. Then timer returns wrapper — the enhanced version of our original function.

The line @timer above process_data is exactly equivalent to writing:

process_data = timer(process_data)

That’s the whole trick. The @ symbol just says “take the function below me, pass it through the decorator above me, and reassign the name to whatever comes back.”

The Problem You’ll Hit Immediately (and How to Fix It)

Try checking the name of your decorated function:

@timer
def process_data():
    time.sleep(0.5)
    return "done"

print(process_data.__name__)  # Output: wrapper — wait, what?

Your function’s identity got replaced by the wrapper. This breaks debugging, introspection, and anything that relies on __name__ or __doc__. The fix is one import away:

import functools

def timer(func):
    @functools.wraps(func)  # This line preserves metadata
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        elapsed = time.time() - start
        print(f"{func.__name__} took {elapsed:.4f}s")
        return result
    return wrapper

@functools.wraps(func) copies the original function’s name, docstring, and other metadata onto the wrapper. Always use it. No exceptions — even for quick scripts. Future you will thank present you when a traceback actually points to the right function.

Decorators With Arguments: Adding Flexibility

The timer decorator is useful, but what if you want to configure it? Maybe you want a retry decorator that lets you specify how many attempts to make:

import functools
import time

def retry(max_attempts=3, delay=1):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            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 in {delay}s...")
                    time.sleep(delay)
            return None
        return wrapper
    return decorator

@retry(max_attempts=5, delay=2)
def call_external_api(endpoint):
    # This might fail transiently
    return requests.get(endpoint).json()

Notice the structure: retry is a function that returns a decorator. When you write @retry(max_attempts=5, delay=2), Python calls retry(max_attempts=5, delay=2) first. The return value — decorator — is the actual decorator, which then receives the function. This three-layer nesting pattern is standard for any decorator that takes arguments.

I use this retry decorator in almost every project that talks to external APIs. Combined with the testing patterns I covered in my pytest tutorial, it turns flaky integration tests from a source of anxiety into something you can actually reason about.

Class-Based Decorators: When You Need State

Function-based decorators are clean for simple cases, but what if the decorator needs to remember something across multiple calls? A call counter, for example:

import functools

class CountCalls:
    def __init__(self, func):
        functools.update_wrapper(self, func)
        self.func = func
        self.count = 0

    def __call__(self, *args, **kwargs):
        self.count += 1
        print(f"{self.func.__name__} called {self.count} time(s)")
        return self.func(*args, **kwargs)

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

print(greet("Alice"))  # greet called 1 time(s)
print(greet("Bob"))    # greet called 2 time(s)
print(greet("Charlie"))# greet called 3 time(s)

Classes give you natural state management — the self.count attribute persists across calls without any closure tricks. The class needs two things: an __init__ that accepts the function (use functools.update_wrapper instead of @wraps for classes), and a __call__ method that makes instances callable. I reach for class-based decorators when the wrapper logic gets complex enough that I’d rather have named methods than a nesting doll of inner functions.

Where Decorators Actually Shine

Once you internalize the pattern, you’ll start seeing opportunities everywhere. Here are four places I use them constantly:

1. Logging and Debugging

Add consistent logging to any function without touching its body. Change the logging format once in the decorator, and every decorated function follows suit.

2. Authentication and Authorization

Frameworks like FastAPI use decorators to protect routes — @app.get("/users") and @requires_auth are both decorators. Understanding how they work under the hood means you can build custom middleware that fits your exact authorization model instead of contorting yourself into what the framework gives you.

3. Caching and Memoization

A @cache(ttl=300) decorator can wrap any expensive function — database queries, API calls, computation-heavy algorithms — and automatically skip recalculation when the result is already known.

4. Rate Limiting

When building tools like the Python CLI applications I’ve written about before, a @rate_limit(calls_per_minute=60) decorator keeps you from accidentally hammering an API.

Common Pitfalls Worth Avoiding

Don’t run expensive setup inside the wrapper. Code inside wrapper runs on every function call. Code outside wrapper (but inside the decorator) runs once at decoration time. If you’re opening a database connection or loading a config file, do it at decoration time.

Don’t swallow exceptions silently. A decorator that catches all exceptions and returns None will hide bugs for months. Always re-raise or at least log before swallowing.

Watch your stack traces. A decorator that modifies arguments or return values can produce deeply confusing tracebacks. Add clear error messages at the decorator boundary so you know which layer broke.

Test decorators in isolation. Write a dummy function, decorate it, and verify the wrapper behaves correctly before combining decorators. The SQLite FTS5 search engine I built uses several nested decorators, and testing each one independently saved me hours of debugging.

A Pattern You’ll Use Forever

Decorators are one of those Python features where the learning curve is steep but short. Spend 20 minutes understanding the wrapper pattern and the @ syntax, and you’ve got a tool that pays dividends in every project. They don’t just reduce boilerplate — they separate concerns in a way that makes your code more testable, more readable, and more maintainable.

The next time you find yourself copy-pasting the same five lines above every function in a file, stop. Write a decorator instead. Your future self — the one debugging this code at 2 AM — will be grateful you did.

Filed under Tech & Gadgets
Last Update: June 18, 2026 by Felix AlterEgo
0 0 votes
Article Rating
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Newest
Oldest Most Voted