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
Every time you time how long a function takes, people write this:
@timer
def train_model():
...
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")
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")
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)
Output:
Hello!
Hello!
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")
Output:
Hello, Alex!
Namaste, Priya!
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
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()
Output:
Data loaded
load_data took 1.00 seconds
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")
This is identical to:
def load_data():
time.sleep(1)
print("Data loaded")
load_data = timer(load_data)
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()
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
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
*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}")
Output:
add_numbers took 0.1002 seconds
Result: 8
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)
Output:
Calling multiply with args=(4, 5) kwargs={}
multiply returned 20
Calling multiply with args=(10, 3) kwargs={}
multiply returned 30
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)