Python Decorators Made Simple: A Complete Beginner’s Guide (2026)
Picture this: you have ten different Python functions spread across your project. Each one needs to log when it was called, how long it took to run, and whether the user is authenticated. So you paste the same fifteen lines of boilerplate code into every single function. Then a requirement changes, and you update one copy — but forget three others. Sound familiar?
This is exactly the kind of problem that Python decorators were designed to eliminate.
If you have spent any time reading Python code, you have almost certainly seen the mysterious @ symbol hovering above function definitions. Maybe you used @staticmethod in a class, or spotted @app.route in a Flask project. These are decorators — and for many beginners, they look like magic. The truth is far simpler and far more elegant.
By the end of this guide, you will fully understand what decorators are, how they work under the hood, and how to write your own from scratch. You will also see exactly where and why they are used in professional Python projects — from logging and authentication to performance optimization and email alerts.
This guide is designed for beginners and intermediate Python learners. If you are brand new to Python functions and user input, it is worth reading our guide on how to take user input in Python first — it builds the foundation you will need here.
Let’s pull back the curtain on Python decorators — step by step, concept by concept, with practical code all the way through.
What Are Python Decorators?
At its core, a Python decorator is a function that wraps another function to modify or extend its behavior — without changing the original source code.
Think of it this way. Imagine your phone. A phone case does not change how your phone works. It still makes calls, runs apps, and takes photos exactly as before. But the case adds protection, changes the appearance, and might even add new functionality like a built-in wallet. A Python decorator works the same way. It wraps your original function, leaves its core logic completely untouched, and adds new behavior around it.
Here is the simplest possible decorator in Python:
def my_decorator(func):
def wrapper():
print("Something happens before the function runs.")
func()
print("Something happens after the function runs.")
return wrapper
@my_decorator
def say_hello():
print("Hello, World!")
say_hello()
Output:
Something happens before the function runs.
Hello, World!
Something happens after the function runs.
The @my_decorator line above say_hello tells Python: “Before you use say_hello, wrap it inside my_decorator first.” The function still does its job — printing “Hello, World!” — but now it has extra behavior before and after, provided by the decorator.
Decorators are the backbone of some of Python’s most popular frameworks. Flask uses @app.route to map URLs to functions. FastAPI uses @app.get for HTTP endpoints. Django uses @login_required to protect views. pytest uses @pytest.fixture for test setup. Understanding decorators gives you a window into how these frameworks actually work.
Functions as First-Class Objects
Before decorators will make complete sense, there is one concept you absolutely need to grasp: in Python, functions are first-class objects.
This is not a complicated idea. “First-class” simply means that functions can be treated like any other value in Python. Specifically, a Python function can be:
- Assigned to a variable
- Passed as an argument to another function
- Returned as a value from another function
Let’s see each of these in action.
Assigning a Function to a Variable
def greet(name):
return f"Hello, {name}!"
# Assign the function to a variable (no parentheses — we're not calling it)
say_hi = greet
print(say_hi("Alice")) # Output: Hello, Alice!
print(greet("Alice")) # Output: Hello, Alice!
Both say_hi and greet point to the same function object. We have not called the function — we have just given it a second name.
Passing a Function as an Argument
def greet(name):
return f"Hello, {name}!"
def run_function(func, value):
return func(value)
result = run_function(greet, "Bob")
print(result) # Output: Hello, Bob!
Here, greet is passed into run_function as an argument — just like you would pass a number or a string.
Returning a Function from Another Function
def get_greeter(style):
def formal(name):
return f"Good day to you, {name}."
def casual(name):
return f"Hey, {name}!"
if style == "formal":
return formal
else:
return casual
my_greeter = get_greeter("casual")
print(my_greeter("Charlie")) # Output: Hey, Charlie!
get_greeter returns a function — not the result of calling one. This is the critical pattern that makes decorators possible.
Why does this matter? Because a decorator literally receives a function as an argument and returns a new function in its place. If you understand the three examples above, you already understand the mechanical heart of every decorator.
This concept also connects closely to object-oriented programming. For a deeper look at how Python treats everything as an object — including functions — our OOP in Python guide covers this in full detail.
Understanding Closures
Now that you understand first-class functions, there is one more concept to cover before writing your first decorator: closures.
A closure is a nested (inner) function that remembers variables from the scope of its outer function, even after the outer function has finished running.
Here is a simple example:
def make_multiplier(factor):
def multiply(number):
return number * factor # 'factor' comes from the outer function
return multiply
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(5)) # Output: 10
print(triple(5)) # Output: 15
When make_multiplier(2) is called and returns multiply, the outer function is finished. But multiply still remembers that factor = 2. That memory is the closure.
You might be wondering: “Why does this matter for decorators?”
Because every decorator you will ever write uses this exact pattern. The wrapper function inside your decorator is a closure — it remembers the func variable from the outer decorator function, even after the outer function has returned. This is how the wrapper can call the original function later, at any point in time.
Let’s trace through the relationship:
make_decorator(func) ← outer function, receives original function
└── wrapper(*args, **kwargs) ← inner function (closure), remembers func
└── func(...) ← calls the original function using closure memory
return wrapper ← returns the closure
Once you see this structure clearly, decorators stop looking like magic and start looking like an elegant pattern built from things you already know.
Basic Decorator Syntax — What the @ Symbol Actually Does
Here is something many beginners do not realize at first: the @ symbol is just a shortcut. It does not do anything mysterious on its own.
When you write:
@my_decorator
def say_hello():
print("Hello!")
Python translates this internally into:
def say_hello():
print("Hello!")
say_hello = my_decorator(say_hello)
That is it. The @my_decorator syntax is called syntactic sugar — it is a cleaner, more readable way of writing say_hello = my_decorator(say_hello). The two forms are 100% identical in behavior.
This means:
- The decorator runs at definition time, not at call time
- Every time you call
say_hello(), you are actually callingwrapper()— the function thatmy_decoratorreturned - The original
say_hellostill exists inside the wrapper (thanks to the closure)
Let’s verify this with a complete example:
def my_decorator(func):
def wrapper():
print("Before")
func()
print("After")
return wrapper
# Long form — exactly what @ does behind the scenes:
def greet():
print("Hello!")
greet = my_decorator(greet)
greet()
Output:
Before
Hello!
After
Now with the @ shorthand — identical result:
@my_decorator
def greet():
print("Hello!")
greet()
Output:
Before
Hello!
After
One important thing to keep in mind: when you stack decorators, a common source of confusion arises about which one runs first. We will cover that in the decorators-with-arguments section. For now, just remember that @decorator is shorthand for func = decorator(func).
When decorator errors do occur, knowing how to read Python tracebacks becomes essential. Our step-by-step Python debugging guide is a great companion resource for tracking down those tricky wrapper-related bugs.
Creating Your First Custom Decorator
You now have all the building blocks. Let’s put them together and build a real, useful custom decorator step by step.
Step 1 — A Decorator Without functools.wraps (The Problem)
Let’s build a simple decorator that logs when a function starts and finishes:
def log_calls(func):
def wrapper(*args, **kwargs):
print(f"Calling: {func.__name__}")
result = func(*args, **kwargs)
print(f"Finished: {func.__name__}")
return result
return wrapper
@log_calls
def add_numbers(a, b):
"""Returns the sum of two numbers."""
return a + b
print(add_numbers(3, 7))
print(add_numbers.__name__) # Output: wrapper ← Problem!
print(add_numbers.__doc__) # Output: None ← Problem!
The decorator works — it logs the call and returns the result. But notice: add_numbers.__name__ now says wrapper, and the original docstring is gone. This happens because the decorator replaced the original function with the wrapper function, including its metadata.
In production code, this causes real problems. Debuggers, logging tools, and documentation generators will show wrong function names. Unit tests can behave unexpectedly.
Step 2 — The Fix: Always Use functools.wraps
Python’s standard library provides a clean solution: @functools.wraps. Apply it to your wrapper function, and it copies the original function’s name, docstring, and all metadata across to the wrapper.
import functools
def log_calls(func):
@functools.wraps(func) # ← This line fixes everything
def wrapper(*args, **kwargs):
print(f"Calling: {func.__name__}")
result = func(*args, **kwargs)
print(f"Finished: {func.__name__}")
return result
return wrapper
@log_calls
def add_numbers(a, b):
"""Returns the sum of two numbers."""
return a + b
print(add_numbers(3, 7))
print(add_numbers.__name__) # Output: add_numbers ✓
print(add_numbers.__doc__) # Output: Returns the sum of two numbers. ✓
The Universal Decorator Template
Memorize this template. Every decorator you write — simple or complex — should follow this structure:
import functools
def my_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
# --- Code to run BEFORE the original function ---
result = func(*args, **kwargs) # Call the original function
# --- Code to run AFTER the original function ---
return result
return wrapper
Why *args, **kwargs? Because your decorator has no idea what arguments the decorated function expects. Using *args and **kwargs makes the wrapper universally flexible — it can accept and pass through any combination of positional and keyword arguments to the original function.
Ready to sharpen these skills with hands-on problems? Our collection of 50 Python coding questions for practice is a great way to reinforce what you’re learning.
Decorators with Arguments
Sometimes you want your decorator to behave differently based on a configuration value. For example:
@repeat(3)— call a function three times@retry(max_attempts=5)— retry a function up to five times on failure@timer(unit="ms")— measure execution time in milliseconds
To accept arguments, you need one extra layer of nesting. The outer function accepts the arguments and returns the decorator. The decorator accepts the function and returns the wrapper. The wrapper does the actual work.
Structure of a Decorator with Arguments
argument_function(config_value) ← Called first, receives config
└── decorator(func) ← Called second, receives the function
└── wrapper(...) ← Called every time the function runs
Example: @repeat(n) Decorator
import functools
def repeat(times):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = None
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(times=3)
def greet(name):
print(f"Hello, {name}!")
greet("Alice")
Output:
Hello, Alice!
Hello, Alice!
Hello, Alice!
When Python sees @repeat(times=3), it:
- Calls
repeat(times=3)→ returnsdecorator - Applies
decoratortogreet→ returnswrapper - Binds the name
greettowrapper
Now every time you call greet("Alice"), you are calling wrapper("Alice").
Stacking Multiple Decorators
Python allows you to apply multiple decorators to a single function. They are applied from bottom to top at definition time:
@decorator_one
@decorator_two
def my_function():
pass
# Python internally processes this as:
# my_function = decorator_one(decorator_two(my_function))
decorator_two wraps my_function first. Then decorator_one wraps the result of that. When my_function() is called, decorator_one‘s code runs first, then decorator_two‘s, then the original function.
Built-In Decorators in Python You Already Have
Python ships with several powerful, ready-to-use decorators. You do not need to write these yourself — they are built right into the language.
@staticmethod — Methods Without self
A static method belongs to the class but does not receive the instance (self) or the class (cls) as its first argument. It is simply a regular function that lives inside a class for organizational purposes.
class MathUtils:
@staticmethod
def add(a, b):
return a + b
print(MathUtils.add(10, 5)) # Output: 15
@classmethod — Methods That Receive the Class
A class method receives the class itself as its first argument (named cls by convention). It is commonly used for alternative constructors.
class Date:
def __init__(self, year, month, day):
self.year = year
self.month = month
self.day = day
@classmethod
def from_string(cls, date_string):
year, month, day = map(int, date_string.split("-"))
return cls(year, month, day)
def __repr__(self):
return f"Date({self.year}, {self.month}, {self.day})"
d = Date.from_string("2026-04-15")
print(d) # Output: Date(2026, 4, 15)
@property — Controlled Attribute Access
@property turns a method into a readable attribute, allowing you to add logic when a value is accessed — without changing how the user interacts with the object.
class Circle:
def __init__(self, radius):
self._radius = radius
@property
def area(self):
import math
return math.pi * self._radius ** 2
c = Circle(5)
print(f"{c.area:.2f}") # Output: 78.54 (accessed like an attribute, not a method)
@functools.lru_cache — Automatic Result Caching
This is one of the most immediately useful built-in decorators. It caches the results of expensive function calls so that repeated calls with the same arguments return instantly from cache.
import functools
@functools.lru_cache(maxsize=128)
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(50)) # Computed instantly with caching
Without @lru_cache, computing fibonacci(50) would make over a trillion recursive calls. With it, each unique value is computed once and remembered.
@dataclass — Auto-Generate Class Boilerplate
Introduced in Python 3.7 and now a staple of modern Python, @dataclass automatically generates __init__, __repr__, and other special methods for your class:
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
label: str = "origin"
p = Point(3.0, 4.0, label="A")
print(p) # Output: Point(x=3.0, y=4.0, label='A')
For a deeper understanding of how @staticmethod and @classmethod fit into Python’s object model, our comprehensive OOP in Python guide covers classes, inheritance, and method types from the ground up.
Real-World Use Cases
This is where decorators transform from an academic concept into a daily professional tool. The following use cases are drawn directly from real Python projects and production codebases.
Use Case 1: Logging Function Calls
The Problem: You need to track when functions are called, with what arguments, and what they return — without adding print statements inside every function.
import functools
import datetime
def log_calls(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
print(f"[{timestamp}] Calling '{func.__name__}' with args={args}, kwargs={kwargs}")
result = func(*args, **kwargs)
print(f"[{timestamp}] '{func.__name__}' returned: {result}")
return result
return wrapper
@log_calls
def calculate_tax(amount, rate=0.15):
return round(amount * rate, 2)
calculate_tax(1000)
calculate_tax(2500, rate=0.20)
Output:
[2026-04-15 10:30:00] Calling 'calculate_tax' with args=(1000,), kwargs={}
[2026-04-15 10:30:00] 'calculate_tax' returned: 150.0
[2026-04-15 10:30:00] Calling 'calculate_tax' with args=(2500,), kwargs={'rate': 0.20}
[2026-04-15 10:30:00] 'calculate_tax' returned: 500.0
You can easily extend this to write logs to a file instead of printing them to the console. Our guide on reading and writing text files in Python shows you exactly how to do this — making your logging decorator fully production-ready.
Use Case 2: Measuring Execution Time
The Problem: You need to identify slow functions during development or performance profiling.
import functools
import time
def timer(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
end = time.perf_counter()
elapsed = end - start
print(f"'{func.__name__}' completed in {elapsed:.4f} seconds")
return result
return wrapper
@timer
def process_large_dataset(data):
# Simulate heavy processing
total = sum(x ** 2 for x in data)
return total
data = list(range(1, 1_000_001))
result = process_large_dataset(data)
Output:
'process_large_dataset' completed in 0.0842 seconds
Use time.perf_counter() rather than time.time() for benchmarking — it provides higher resolution and is not affected by system clock adjustments.
Use Case 3: Authentication and Access Control
The Problem: Certain functions or routes should only be accessible to authenticated users. Repeating authentication checks in every function is error-prone and hard to maintain.
import functools
# Simulated session store
current_user = {"username": "alice", "is_authenticated": True, "role": "admin"}
def login_required(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
if not current_user.get("is_authenticated"):
print("Access Denied: You must be logged in to perform this action.")
return None
return func(*args, **kwargs)
return wrapper
def admin_only(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
if current_user.get("role") != "admin":
print("Access Denied: Admin privileges required.")
return None
return func(*args, **kwargs)
return wrapper
@login_required
@admin_only
def delete_user(user_id):
print(f"User {user_id} has been deleted.")
delete_user(42)
Output:
User 42 has been deleted.
This exact pattern powers @login_required in Django and similar decorators in Flask. To see authentication decorators in action inside a real web application, our Flask beginner’s guide walks you through building a complete website step by step, including route protection.
Use Case 4: Caching Expensive Results
The Problem: A function performs a costly computation (database query, API call, heavy calculation) and is called repeatedly with the same inputs.
Solution 1 — Use Python’s built-in @lru_cache:
import functools
@functools.lru_cache(maxsize=256)
def get_product_price(product_id):
# Simulate a slow database query
import time
time.sleep(0.5)
prices = {101: 29.99, 102: 49.99, 103: 9.99}
return prices.get(product_id, 0.0)
# First call: slow (hits the "database")
print(get_product_price(101)) # 0.5 seconds
# Second call: instant (served from cache)
print(get_product_price(101)) # Nearly 0 seconds
Solution 2 — Build a manual cache decorator to understand the mechanics:
import functools
def simple_cache(func):
cache = {}
@functools.wraps(func)
def wrapper(*args):
if args not in cache:
cache[args] = func(*args)
print(f"Cache MISS for {func.__name__}{args}")
else:
print(f"Cache HIT for {func.__name__}{args}")
return cache[args]
return wrapper
@simple_cache
def square(n):
return n * n
print(square(4)) # Cache MISS → computes 16
print(square(4)) # Cache HIT → returns 16 instantly
print(square(9)) # Cache MISS → computes 81
Caching becomes especially important when working with large datasets. Our Pandas tutorial for beginners demonstrates how to handle and optimize large data operations efficiently in Python.
Use Case 5: Retry Logic for Unreliable Operations
The Problem: Network requests, API calls, and file operations can fail intermittently. You want to automatically retry failed operations without cluttering your business logic.
import functools
import time
def retry(max_attempts=3, delay=1.0):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_exception = None
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except Exception as e:
last_exception = e
print(f"Attempt {attempt}/{max_attempts} failed: {e}")
if attempt < max_attempts:
time.sleep(delay)
raise last_exception
return wrapper
return decorator
@retry(max_attempts=3, delay=0.5)
def fetch_data_from_api(endpoint):
import random
if random.random() < 0.7: # Simulate 70% failure rate
raise ConnectionError(f"Failed to connect to {endpoint}")
return {"status": "success", "data": [1, 2, 3]}
try:
result = fetch_data_from_api("https://api.example.com/data")
print("Got result:", result)
except ConnectionError:
print("All retry attempts exhausted.")
Use Case 6: Automatic Email Notifications
The Problem: You want to receive an email alert whenever a critical function runs or raises an exception — without embedding email logic inside every function.
import functools
def notify_on_completion(recipient_email):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
try:
result = func(*args, **kwargs)
# In production, send a success email here
print(f"Email sent to {recipient_email}: '{func.__name__}' completed successfully.")
return result
except Exception as e:
# In production, send an error alert here
print(f"Alert sent to {recipient_email}: '{func.__name__}' failed with error: {e}")
raise
return wrapper
return decorator
@notify_on_completion(recipient_email="admin@example.com")
def run_monthly_report():
print("Generating report...")
# Report generation logic here
print("Report complete.")
run_monthly_report()
To learn how to send real emails programmatically using Python’s smtplib module, our guide on sending emails automatically with Python walks you through the full implementation — a natural next step after building this decorator.
Common Mistakes Beginners Make with Decorators
Even experienced developers make these mistakes. Learning them now will save you hours of debugging later.
Mistake 1: Forgetting functools.wraps
This is the single most common decorator mistake. Without @functools.wraps(func), your wrapper function silently replaces the original function’s metadata.
# ❌ Wrong — loses function identity
def my_decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
# ✅ Correct — preserves function identity
import functools
def my_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
Rule: Every custom decorator you write should use @functools.wraps(func). No exceptions.
Mistake 2: Not Using *args, **kwargs in the Wrapper
If your wrapper does not accept flexible arguments, it will break the moment you try to decorate a function that takes parameters.
# ❌ Wrong — breaks for any function with parameters
def my_decorator(func):
@functools.wraps(func)
def wrapper():
return func()
return wrapper
@my_decorator
def add(a, b):
return a + b
add(3, 5) # TypeError: wrapper() takes 0 positional arguments but 2 were given
# ✅ Correct — works for any function signature
def my_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
Mistake 3: Calling the Decorator Instead of Passing It
The @ syntax does not use parentheses unless your decorator is designed to accept arguments.
# ❌ Wrong — calls the decorator immediately, returns None, then tries to apply None
@my_decorator() # ← Parentheses are wrong here (unless decorator takes args)
def my_function():
pass
# ✅ Correct — passes the decorator without calling it
@my_decorator
def my_function():
pass
Exception: If your decorator is a decorator factory (designed to accept arguments), then parentheses are required: @repeat(times=3).
Mistake 4: Using Mutable Default Arguments Inside the Wrapper
Using mutable objects (lists, dictionaries) defined in the decorator scope creates shared state across all calls to the decorated function — a subtle and hard-to-find bug.
# ❌ Wrong — 'call_log' is shared across ALL calls
def my_decorator(func):
call_log = [] # This persists between calls!
@functools.wraps(func)
def wrapper(*args, **kwargs):
call_log.append(args) # Grows forever
return func(*args, **kwargs)
return wrapper
# ✅ Correct — create mutable objects inside the wrapper for isolation
def my_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
call_log = [] # Fresh list every call
call_log.append(args)
return func(*args, **kwargs)
return wrapper
Mistake 5: Misunderstanding Decorator Execution Order When Stacking
Stacked decorators are applied bottom to top at definition time, which means the bottom-most decorator wraps the function first.
@decorator_A
@decorator_B
@decorator_C
def my_function():
pass
# Execution at definition: my_function = decorator_A(decorator_B(decorator_C(my_function)))
# Execution at call time: decorator_A runs → decorator_B runs → decorator_C runs → original
Trace through this mentally with a concrete example before stacking three or more decorators on a function.
Mistake 6: Over-Decorating Simple Logic
Decorators are powerful — but they add indirection and complexity. If a piece of behavior is only used in one place, a decorator is probably overkill. Apply decorators for reusable cross-cutting concerns (logging, authentication, caching), not for one-off logic.
Rule of thumb: If you find yourself copy-pasting the same behavior across three or more functions, it is time for a decorator.
For more Python pitfalls to avoid and interview-ready explanations, our list of top 20 Python interview questions for beginners covers the questions that trip up even experienced developers.
Best Practices for Writing Clean Decorators
Following these practices will ensure your decorators are readable, reliable, and maintainable.
✅ Always Use @functools.wraps
Every single custom decorator you write should include @functools.wraps(func) on the wrapper. Without it, you are silently breaking debugging, documentation, and introspection tools.
✅ Always Use *args, **kwargs
Never assume what arguments the decorated function will have. Make every wrapper universally flexible with *args and **kwargs.
✅ One Decorator, One Responsibility
Each decorator should do exactly one thing. A decorator that logs, times, and authenticates all at once is impossible to test, reuse, or debug independently.
# ❌ One decorator doing too much
@log_and_time_and_authenticate
def my_function():
pass
# ✅ Separate, composable decorators
@log_calls
@timer
@login_required
def my_function():
pass
✅ Document Your Decorators
Write a docstring for every decorator explaining what behavior it adds, what arguments it accepts, and any side effects.
def timer(func):
"""
Measures and prints the execution time of the decorated function.
Output format: '<function_name>' completed in X.XXXX seconds
"""
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
print(f"'{func.__name__}' completed in {time.perf_counter() - start:.4f} seconds")
return result
return wrapper
✅ Test Your Decorators Independently
Write unit tests for both the decorator’s added behavior and the original function’s core logic. Use func.__wrapped__ (available when @functools.wraps is used) to access the unwrapped original function in tests.
import functools
def log_calls(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling: {func.__name__}")
return func(*args, **kwargs)
return wrapper
@log_calls
def multiply(a, b):
return a * b
# Test the original logic without the decorator:
original = multiply.__wrapped__
assert original(3, 4) == 12 # ✓ Tests core logic in isolation
✅ Prefer Function-Based Decorators for Simple Cases
Use function-based decorators for stateless behavior. Only reach for class-based decorators when you genuinely need to maintain state across multiple calls.
Performance Considerations
Decorators are not magic, and they are not free — but their cost is almost always negligible for real-world applications.
The Overhead of a Decorator
Every time you call a decorated function, Python executes one extra function call (the wrapper) in addition to the original function. In practical terms, this overhead is measured in microseconds.
Let’s put this in perspective:
import functools
import time
def do_nothing(func):
"""A decorator that adds zero behavior — just measures overhead."""
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@do_nothing
def simple_add(a, b):
return a + b
# Benchmark: 1 million calls
start = time.perf_counter()
for _ in range(1_000_000):
simple_add(1, 2)
elapsed = time.perf_counter() - start
print(f"1 million decorated calls: {elapsed:.3f} seconds")
For most projects, this overhead is completely irrelevant. Web requests, database queries, and network calls all take orders of magnitude longer than a decorator wrapper.
Performance Reference Table
| Decorator Type | Typical Overhead | Notes |
|---|---|---|
| Simple logging decorator | ~1–5 microseconds per call | Negligible for most applications |
| Authentication decorator | Depends on auth logic | DB or API calls dominate — not the wrapper |
@functools.lru_cache | Slight memory cost | Massive speed gain on repeat calls |
| Stacked decorators (3+) | Slightly additive | Still negligible outside tight loops |
| Decorator with arguments | Same as simple decorator | Extra nesting is a one-time cost at definition |
When Performance Might Matter
If a decorated function is called millions of times per second inside a tight computational loop (scientific computing, game engines, real-time signal processing), the overhead becomes measurable. In these cases:
- Use
@functools.lru_cacheaggressively for pure functions - Profile first with the
timeitmodule before optimizing - Consider removing decorators from the absolute hottest paths
Important: Watch What Runs at Definition Time
Code inside the decorator function body (but outside the wrapper) runs once at definition time — not every time the function is called.
def my_decorator(func):
print("This runs ONCE when the decorator is applied") # ← At definition time
@functools.wraps(func)
def wrapper(*args, **kwargs):
print("This runs EVERY time the function is called") # ← At call time
return func(*args, **kwargs)
return wrapper
Accidentally placing expensive setup code outside the wrapper is a common source of unexpected slow imports and module load times.
FAQs
Q1: What is a Python decorator in simple terms?
A Python decorator is a function that wraps another function to add extra behavior — like logging, timing, or authentication — without changing the original function’s code. You apply it using the @decorator_name syntax directly above a function definition. When Python sees the @ symbol, it replaces your function with the wrapped version returned by the decorator.
Q2: Do I always need functools.wraps when writing a decorator?
Yes — for any decorator you plan to use in production. Without @functools.wraps(func), the decorated function loses its original name, docstring, and all metadata. This breaks debuggers, documentation generators, and logging tools. It takes one line to fix and should be considered non-negotiable in professional Python code.
Q3: What is the difference between @staticmethod and @classmethod?
@staticmethod creates a method that receives neither the instance (self) nor the class (cls). It is a plain function that lives inside a class for organizational reasons. @classmethod receives the class itself (cls) as its first argument, making it useful for writing alternative constructors or factory methods that need access to class-level data.
Q4: Can a function have multiple decorators?
Yes. You stack multiple decorators by listing them above the function definition, one per line. They are applied from bottom to top at definition time. At call time, the topmost decorator’s code runs first, then the next one down, and so on to the original function.
Q5: What is the difference between a decorator and a closure?
A closure is an inner function that remembers variables from its enclosing scope after the outer function has returned. A decorator uses a closure as its internal mechanism — the wrapper function inside every decorator is a closure. The closure is the concept; the decorator is the design pattern that applies that concept to function enhancement.
Q6: Are Python decorators bad for performance?
For the vast majority of applications, no. The overhead of a decorator wrapper is measured in microseconds — far smaller than any I/O operation, network call, or database query. The built-in @functools.lru_cache actually improves performance significantly for functions with repeated inputs. Only in extremely performance-sensitive tight loops should decorator overhead be considered.
Q7: Where are decorators used in real Python projects?
Decorators are core infrastructure in Python’s most popular frameworks. Flask uses @app.route for URL routing. Django uses @login_required for view protection. FastAPI uses @app.get for API endpoints. pytest uses @pytest.fixture for test configuration. Beyond frameworks, decorators are used in virtually every professional Python codebase for logging, caching, retry logic, rate limiting, and input validation.
Q8: What is a decorator factory (decorator with arguments)?
A decorator factory is a function that accepts configuration arguments and returns a decorator. For example, @retry(max_attempts=3) calls retry(max_attempts=3) first, which returns the actual decorator, which then wraps your function. This three-layer structure is the standard pattern for any decorator that needs to be configured at application time.
Conclusion
Let’s quickly trace the journey you have just completed.
You started by understanding that Python functions are first-class objects — they can be stored in variables, passed as arguments, and returned from other functions. That single insight is the foundation everything else rests on.
From there, you learned about closures — how inner functions remember variables from their enclosing scope. This is the invisible engine running inside every decorator you will ever write.
With that foundation solid, you learned the @ syntax — not as magic, but as clean shorthand for func = decorator(func). You built your first custom decorator using the universal template with @functools.wraps and *args, **kwargs. You leveled up to decorators with arguments, learned all the key built-in decorators, and saw exactly how decorators are applied in real-world scenarios: logging, timing, authentication, caching, retry logic, and email notifications.
