DEV Community

Cover image for Decorators: The Python Feature That Looks Like Magic
Akhilesh
Akhilesh

Posted on

Decorators: The Python Feature That Looks Like Magic

Something has been quietly happening in code you have never written yet.

Every time you use FastAPI to build an endpoint, you will write this:

@app.get("/users")
def get_users():
    return users
Enter fullscreen mode Exit fullscreen mode

Every time you time how long a function takes, people write this:

@timer
def train_model():
    ...
Enter fullscreen mode Exit fullscreen mode

Every time Flask defines a route, every time you cache a result, every time you check if a user is logged in before running a function, that @ symbol is right there at the top.

You have probably seen it and moved past it. Today you stop moving past it.


Start With a Problem

Imagine you have three functions and you want to print how long each one takes to run.

import time

def load_data():
    time.sleep(1)
    print("Data loaded")

def train_model():
    time.sleep(2)
    print("Model trained")

def save_results():
    time.sleep(0.5)
    print("Results saved")
Enter fullscreen mode Exit fullscreen mode

The obvious approach: add timing code to each function.

def load_data():
    start = time.time()
    time.sleep(1)
    print("Data loaded")
    end = time.time()
    print(f"load_data took {end - start:.2f} seconds")
Enter fullscreen mode Exit fullscreen mode

Repeat that for all three. Now your timing logic is copy-pasted everywhere. If you want to change the format of the timer message, you change it in three places. When you add a fourth function, you copy it again.

There is a better way.


Functions Can Receive Other Functions

Before decorators make sense, you need to see one thing. In Python, functions are just values. You can pass a function to another function the same way you pass a number or a string.

def say_hello():
    print("Hello!")

def run_twice(func):
    func()
    func()

run_twice(say_hello)
Enter fullscreen mode Exit fullscreen mode

Output:

Hello!
Hello!
Enter fullscreen mode Exit fullscreen mode

say_hello got passed into run_twice as an argument. run_twice called it twice. The function is just a value that happens to be callable.


Functions Can Return Other Functions

This is the piece that makes decorators possible.

def make_greeting(language):
    def greet(name):
        if language == "english":
            print(f"Hello, {name}!")
        elif language == "hindi":
            print(f"Namaste, {name}!")
    return greet

english_greet = make_greeting("english")
hindi_greet = make_greeting("hindi")

english_greet("Alex")
hindi_greet("Priya")
Enter fullscreen mode Exit fullscreen mode

Output:

Hello, Alex!
Namaste, Priya!
Enter fullscreen mode Exit fullscreen mode

make_greeting returns a function. Not the result of calling a function. The function itself. english_greet is now a function that says hello in English. hindi_greet is a function that says hello in Hindi.

A function that creates and returns another function. Hold that thought.


Building a Decorator by Hand

Now combine both ideas. Take a function in. Create a new function that wraps it. Return the new function.

import time

def timer(func):
    def wrapper():
        start = time.time()
        func()
        end = time.time()
        print(f"{func.__name__} took {end - start:.2f} seconds")
    return wrapper
Enter fullscreen mode Exit fullscreen mode

timer takes a function. Creates wrapper which runs the original function sandwiched between timing code. Returns wrapper.

Use it:

def load_data():
    time.sleep(1)
    print("Data loaded")

load_data = timer(load_data)
load_data()
Enter fullscreen mode Exit fullscreen mode

Output:

Data loaded
load_data took 1.00 seconds
Enter fullscreen mode Exit fullscreen mode

load_data = timer(load_data) replaces the original load_data with the wrapped version. Now every call to load_data runs the timer automatically.

This is manually decorating a function. Works perfectly.


The @ Syntax Is Just Shorthand

Python gives you a cleaner way to write exactly that.

@timer
def load_data():
    time.sleep(1)
    print("Data loaded")
Enter fullscreen mode Exit fullscreen mode

This is identical to:

def load_data():
    time.sleep(1)
    print("Data loaded")

load_data = timer(load_data)
Enter fullscreen mode Exit fullscreen mode

The @timer above the function is Python saying: take this function, pass it through timer, and replace it with whatever comes back. Same operation, cleaner syntax.

Now apply it to all three functions:

@timer
def load_data():
    time.sleep(1)
    print("Data loaded")

@timer
def train_model():
    time.sleep(2)
    print("Model trained")

@timer
def save_results():
    time.sleep(0.5)
    print("Results saved")

load_data()
train_model()
save_results()
Enter fullscreen mode Exit fullscreen mode

Output:

Data loaded
load_data took 1.00 seconds
Model trained
train_model took 2.00 seconds
Results saved
save_results took 0.50 seconds
Enter fullscreen mode Exit fullscreen mode

Timing logic written once. Applied to three functions with one line each. Change the timer format once and it updates everywhere.


Handling Functions With Arguments

The timer above only works on functions with no arguments. Real functions take parameters.

import time
from functools import wraps

def timer(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        end = time.time()
        print(f"{func.__name__} took {end - start:.4f} seconds")
        return result
    return wrapper
Enter fullscreen mode Exit fullscreen mode

*args captures any positional arguments. **kwargs captures any keyword arguments. Together they handle any function signature. Whatever you pass to the decorated function gets forwarded to the original.

@wraps(func) preserves the original function's name and documentation. Without it, every decorated function would show up as wrapper when you inspect it.

return result passes back whatever the original function returned.

This is the proper production-ready decorator shape. Use this version always.

@timer
def add_numbers(a, b):
    time.sleep(0.1)
    return a + b

result = add_numbers(5, 3)
print(f"Result: {result}")
Enter fullscreen mode Exit fullscreen mode

Output:

add_numbers took 0.1002 seconds
Result: 8
Enter fullscreen mode Exit fullscreen mode

A Decorator You Will Actually Use

Logging. Know when a function was called and with what arguments.

from functools import wraps

def log(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__} with args={args} kwargs={kwargs}")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned {result}")
        return result
    return wrapper

@log
def multiply(a, b):
    return a * b

multiply(4, 5)
multiply(10, 3)
Enter fullscreen mode Exit fullscreen mode

Output:

Calling multiply with args=(4, 5) kwargs={}
multiply returned 20
Calling multiply with args=(10, 3) kwargs={}
multiply returned 30
Enter fullscreen mode Exit fullscreen mode

Add @log to any function and you instantly see every call with its inputs and outputs. Remove it and all the logging disappears. The function is untouched.

This is the real power. Behavior added and removed without touching the original function.


Try This

Create decorators_practice.py.

Write a decorator called validate_positive that checks all the arguments passed to a function. If any argument is zero or negative, it prints an error message and returns None without calling the original function. If all arguments are positive, it calls the function normally and returns the result.

Test it on a function called calculate_area(length, width) that returns length * width.

Then write a second decorator called repeat that calls the decorated function three times in a row.

Apply repeat to a function called say_something(message) that just prints the message. Call it once and watch it print three times.


Phase 1 Is Done

That is all fifteen core Python concepts. Variables, types, conditions, loops, functions, lists, dictionaries, classes, files, errors, modules, comprehensions, lambda, map, filter, and now decorators.

You know enough Python to follow anything that comes next.

Phase 2 starts now. The math behind AI. Not theory for its own sake. The exact concepts your models use when they learn, the ones you need to understand so you know what is actually happening when you train a neural network.

Top comments (0)