Python Decorators Made Easy: A Beginner's Guide
The Decorator Mystery
If you've been learning Python for a while, you've probably encountered decorators. They look mysterious with their @ symbols and can seem intimidating at first glance. But here's the secret: decorators are just functions that modify other functions. They're one of Python's most elegant features, and once you understand them, you'll find countless uses for them.
In this guide, we'll demystify decorators and show you exactly how they work, from the simplest examples to real-world applications.
What Exactly is a Decorator?
A decorator is a function that takes another function as an argument, adds some functionality, and returns a new function. It's like a gift wrapper for functions—the wrapper enhances the gift without changing its original purpose.
def my_decorator(func):
def wrapper():
print("Something is happening before the function is called.")
func()
print("Something is happening after the function is called.")
return wrapper
That's it! The wrapper function adds behavior before and after the original function runs.
Understanding Functions as First-Class Citizens
To understand decorators, you need to know that functions in Python are first-class objects. This means:
-
Functions can be assigned to variables
-
Functions can be passed as arguments to other functions
-
Functions can be returned from other functions
def greet():
return "Hello!"
# Assign to variable
say_hello = greet
print(say_hello()) # Hello!
# Pass as argument
def call_twice(func):
func()
func()
call_twice(greet) # Prints "Hello!" twice
This flexibility is what makes decorators possible.
Your First Decorator
Let's create a simple decorator that measures execution time:
import time
def timer(func):
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
end = time.time()
print(f"{func.__name__} took {end - start:.2f} seconds")
return result
return wrapper
# Using the decorator
@timer
def slow_function():
time.sleep(2)
return "Done!"
print(slow_function()) # "Done!" and prints execution time
The @timer syntax is equivalent to:
slow_function = timer(slow_function)
Decorators with Arguments
Sometimes you want your decorator to accept arguments. This requires an extra layer of nesting:
def repeat(times):
def decorator(func):
def wrapper(*args, **kwargs):
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(times=3)
def say_hello(name):
print(f"Hello, {name}!")
say_hello("Alice") # Prints "Hello, Alice!" three times
Preserving Function Metadata
When you decorate a function, you lose its original metadata like name and docstring. The functools.wraps decorator fixes this:
from functools import wraps
def my_decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
"""Wrapper docstring"""
return func(*args, **kwargs)
return wrapper
@my_decorator
def greet():
"""Original docstring"""
print("Hello!")
print(greet.__name__) # greet (not wrapper)
print(greet.__doc__) # Original docstring (not Wrapper docstring)
Always use @wraps in your decorators—it's a best practice!
Real-World Examples
Let's look at some practical decorator use cases:
1. Authentication Checker
def login_required(func):
@wraps(func)
def wrapper(user, *args, **kwargs):
if not user.get('is_authenticated', False):
raise PermissionError("User not authenticated")
return func(user, *args, **kwargs)
return wrapper
@login_required
def view_profile(user):
return f"Profile of {user['name']}"
# Usage
user = {'name': 'Alice', 'is_authenticated': True}
print(view_profile(user)) # Profile of Alice
2. Retry Logic
def retry(max_attempts=3, delay=1):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(max_attempts):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == max_attempts - 1:
raise
print(f"Attempt {attempt+1} failed. Retrying...")
time.sleep(delay)
return wrapper
return decorator
@retry(max_attempts=3, delay=0.5)
def unstable_function():
import random
if random.random() < 0.7:
raise ValueError("Random failure!")
return "Success!"
3. Caching Results
def cache(func):
cached_results = {}
@wraps(func)
def wrapper(*args):
if args in cached_results:
print(f"Returning cached result for {args}")
return cached_results[args]
result = func(*args)
cached_results[args] = result
return result
return wrapper
@cache
def expensive_function(x, y):
print(f"Computing {x} + {y}...")
return x + y
# First call computes
print(expensive_function(3, 5)) # Computing... 8
# Second call uses cache
print(expensive_function(3, 5)) # Returning cached result... 8
Common Pitfalls and How to Avoid Them
1. Forgetting @wraps
Without @wraps, debugging becomes harder because function names and docstrings are lost.
Problem:
def my_decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@my_decorator
def greet():
"""Say hello"""
print("Hello")
print(greet.__name__) # wrapper (not greet!)
Solution: Always use @wraps from functools.
2. Incorrect Argument Handling
Your wrapper must accept *args and **kwargs to handle any arguments the decorated function might receive.
Problem:
def bad_decorator(func):
def wrapper(): # No parameters!
return func()
return wrapper
@bad_decorator
def greet(name): # Function expects an argument
print(f"Hello {name}")
greet("Alice") # TypeError!
Solution: Use *args, **kwargs in your wrapper.
3. Mutating State Between Calls
Be careful when using mutable objects in your decorator:
def count_calls(func):
call_count = 0 # This is fine, it's per function
@wraps(func)
def wrapper(*args, **kwargs):
nonlocal call_count
call_count += 1
print(f"Call {call_count} of {func.__name__}")
return func(*args, **kwargs)
return wrapper
Advanced Patterns
Class-Based Decorators
Decorators can be classes too:
class CountCalls:
def __init__(self, func):
self.func = func
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
print(f"Call {self.count} of {self.func.__name__}")
return self.func(*args, **kwargs)
@CountCalls
def say_hello():
print("Hello!")
say_hello() # Call 1 of say_hello
say_hello() # Call 2 of say_hello
Decorator Factories
Create customizable decorators:
def log(level="INFO"):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"[{level}] Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
return decorator
@log(level="DEBUG")
def process_data():
print("Processing...")
process_data() # [DEBUG] Calling process_data
Built-in Decorators You Already Use
Python comes with several built-in decorators:
-
@staticmethodand@classmethod- Define static and class methods -
@property- Create properties with getter/setter -
@functools.lru_cache- Cache function results (more efficient than our custom cache) -
@dataclass- Automatically generate special methods
Conclusion: When to Use Decorators
Decorators are perfect for:
-
Logging and debugging - Track function calls
-
Performance monitoring - Measure execution times
-
Access control - Add authentication/authorization
-
Caching - Store expensive computations
-
Retry logic - Handle transient failures
-
Validation - Check inputs/outputs
-
Rate limiting - Control API usage
Remember: decorators should be transparent—they should enhance without changing the underlying function's behavior in unexpected ways.
Contact Us
Phone: +91 9667708830
Email: info@codingnow.in
Website: https://codingnow.in/
Address:
2nd Floor, Kapil Vihar (Opp. Metro Pillar No.354)
Pitampura, New Delhi – 110034
Backlink to main website: Explore Python and AI courses at Coding Now – Gurukul of AI
