Python Decorators: Functions as First-Class Objects

Python Decorators: Functions as First-Class Objects
Decorators are one of Python's most elegant features, allowing you to modify or enhance functions and classes without changing their source code. They're essential for frameworks like Flask, FastAPI, and Django, and understanding them unlocks powerful programming patterns. This guide takes you from the fundamentals of first-class functions to advanced decorator patterns.
What You'll Learn
✅ Functions as first-class objects
✅ Closures and scope
✅ Function wrappers and decorators
✅ Decorator syntax with @
✅ Decorators with arguments
✅ Class decorators
✅ Common decorator patterns
✅ functools.wraps and metadata preservation
Prerequisites
Before diving into decorators, you should understand:
- Python functions from Phase 1: Fundamentals
- Object-oriented programming from Phase 2: OOP & Advanced Features
1. Functions as First-Class Objects
In Python, functions are first-class objects - they can be:
- Assigned to variables
- Passed as arguments to other functions
- Returned from functions
- Stored in data structures
This is the foundation for understanding decorators.
def greet(name: str) -> str:
"""Return a greeting."""
return f"Hello, {name}!"
# Assign function to variable
say_hello = greet
print(say_hello("Alice")) # "Hello, Alice!"
# Store functions in a list
operations: list = [greet, str.upper, str.lower]
for func in operations:
if func == greet:
print(func("Bob")) # "Hello, Bob!"Passing Functions as Arguments
from typing import Callable
def execute_twice(func: Callable[[str], str], value: str) -> None:
"""Execute a function twice."""
print(func(value))
print(func(value))
def shout(text: str) -> str:
return text.upper() + "!"
execute_twice(shout, "hello")
# Output:
# HELLO!
# HELLO!Returning Functions from Functions
def create_multiplier(factor: int) -> Callable[[int], int]:
"""Return a function that multiplies by factor."""
def multiply(x: int) -> int:
return x * factor
return multiply
times_two = create_multiplier(2)
times_five = create_multiplier(5)
print(times_two(10)) # 20
print(times_five(10)) # 50This is our first glimpse of closures - the multiply function "remembers" the factor value even after create_multiplier has finished executing.
2. Understanding Closures
A closure is a function that captures and remembers variables from its enclosing scope, even after that scope has finished executing.
Closure Basics
def outer(x: int) -> Callable[[], int]:
"""Outer function creates a closure."""
# x is in the outer scope
def inner() -> int:
"""Inner function closes over x."""
return x * 2 # Accesses x from outer scope
return inner
closure = outer(10)
print(closure()) # 20
# x is no longer accessible, but closure remembers it
another = outer(5)
print(another()) # 10Closures with Mutable State
def create_counter() -> Callable[[], int]:
"""Create a counter function."""
count = 0 # Enclosed variable
def increment() -> int:
nonlocal count # Modify enclosed variable
count += 1
return count
return increment
counter1 = create_counter()
print(counter1()) # 1
print(counter1()) # 2
print(counter1()) # 3
counter2 = create_counter()
print(counter2()) # 1 (separate closure)The nonlocal keyword tells Python to modify the variable from the enclosing scope, not create a new local variable.
Why Closures Matter for Decorators
Decorators use closures to "wrap" functions while preserving access to the original function and any decorator arguments.
3. Basic Function Decorators
A decorator is a function that takes another function and extends its behavior without explicitly modifying it.
Simple Decorator Example
from typing import Callable, Any
def my_decorator(func: Callable[..., Any]) -> Callable[..., Any]:
"""A simple decorator that wraps a function."""
def wrapper(*args: Any, **kwargs: Any) -> Any:
print("Before function call")
result = func(*args, **kwargs)
print("After function call")
return result
return wrapper
# Manual decoration
def say_hello(name: str) -> str:
return f"Hello, {name}!"
decorated_hello = my_decorator(say_hello)
print(decorated_hello("Alice"))
# Output:
# Before function call
# After function call
# Hello, Alice!The @ Syntax
Python provides syntactic sugar for decorators using @:
@my_decorator
def say_goodbye(name: str) -> str:
return f"Goodbye, {name}!"
# Equivalent to: say_goodbye = my_decorator(say_goodbye)
print(say_goodbye("Bob"))
# Output:
# Before function call
# After function call
# Goodbye, Bob!4. Practical Decorator Examples
Timing Decorator
Measure function execution time:
import time
from typing import Callable, Any
from functools import wraps
def timer(func: Callable[..., Any]) -> Callable[..., Any]:
"""Decorator to measure execution time."""
@wraps(func) # Preserve original function metadata
def wrapper(*args: Any, **kwargs: Any) -> Any:
start_time = time.time()
result = func(*args, **kwargs)
end_time = time.time()
duration = end_time - start_time
print(f"{func.__name__} took {duration:.4f} seconds")
return result
return wrapper
@timer
def slow_function(n: int) -> int:
"""Simulate slow operation."""
time.sleep(1)
return n * 2
result = slow_function(5)
# Output: slow_function took 1.0012 seconds
print(result) # 10Logging Decorator
Log function calls with arguments:
import logging
from typing import Callable, Any
from functools import wraps
logging.basicConfig(level=logging.INFO)
def log_call(func: Callable[..., Any]) -> Callable[..., Any]:
"""Decorator to log function calls."""
@wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
args_str = ", ".join(repr(arg) for arg in args)
kwargs_str = ", ".join(f"{k}={v!r}" for k, v in kwargs.items())
all_args = ", ".join(filter(None, [args_str, kwargs_str]))
logging.info(f"Calling {func.__name__}({all_args})")
result = func(*args, **kwargs)
logging.info(f"{func.__name__} returned {result!r}")
return result
return wrapper
@log_call
def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
result = add(5, 3)
# INFO:root:Calling add(5, 3)
# INFO:root:add returned 8Retry Decorator
Automatically retry failed operations:
import time
from typing import Callable, Any, Type
from functools import wraps
def retry(
max_attempts: int = 3,
delay: float = 1.0,
exceptions: tuple[Type[Exception], ...] = (Exception,)
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
"""Decorator to retry a function on failure."""
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
@wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
last_exception = None
for attempt in range(max_attempts):
try:
return func(*args, **kwargs)
except exceptions as e:
last_exception = e
if attempt < max_attempts - 1:
print(f"Attempt {attempt + 1} failed, retrying in {delay}s...")
time.sleep(delay)
else:
print(f"All {max_attempts} attempts failed")
raise last_exception # type: ignore
return wrapper
return decorator
@retry(max_attempts=3, delay=0.5, exceptions=(ValueError,))
def unreliable_function(value: int) -> int:
"""Function that sometimes fails."""
import random
if random.random() < 0.7: # 70% failure rate
raise ValueError("Random failure!")
return value * 2
# Will retry up to 3 times on ValueError
try:
result = unreliable_function(10)
print(f"Success: {result}")
except ValueError:
print("Failed after all retries")5. Decorators with Arguments
To create decorators that accept arguments, you need an extra layer of nesting.
Repeat Decorator
from typing import Callable, Any
from functools import wraps
def repeat(times: int) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
"""Decorator that repeats function execution."""
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
@wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
result = None
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(times=3)
def greet(name: str) -> None:
print(f"Hello, {name}!")
greet("Alice")
# Output:
# Hello, Alice!
# Hello, Alice!
# Hello, Alice!Validation Decorator
from typing import Callable, Any
from functools import wraps
def validate_positive(func: Callable[..., Any]) -> Callable[..., Any]:
"""Ensure all numeric arguments are positive."""
@wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
# Check positional arguments
for arg in args:
if isinstance(arg, (int, float)) and arg < 0:
raise ValueError(f"Argument must be positive, got {arg}")
# Check keyword arguments
for value in kwargs.values():
if isinstance(value, (int, float)) and value < 0:
raise ValueError(f"Argument must be positive, got {value}")
return func(*args, **kwargs)
return wrapper
@validate_positive
def calculate_area(width: float, height: float) -> float:
"""Calculate rectangle area."""
return width * height
print(calculate_area(5.0, 3.0)) # 15.0
# calculate_area(-5.0, 3.0) # Raises ValueError6. Stacking Multiple Decorators
You can apply multiple decorators to a single function:
@timer
@log_call
@validate_positive
def multiply(a: int, b: int) -> int:
"""Multiply two numbers."""
return a * b
# Equivalent to:
# multiply = timer(log_call(validate_positive(multiply)))
result = multiply(5, 3)
# Decorators execute from bottom to top:
# 1. validate_positive checks arguments
# 2. log_call logs the call
# 3. timer measures execution timeOrder matters! Decorators are applied from bottom to top (closest to the function first).
7. Class Decorators
Decorators can also modify classes:
from typing import Type, Any
def singleton(cls: Type[Any]) -> Type[Any]:
"""Decorator to make a class a singleton."""
instances: dict[Type[Any], Any] = {}
def get_instance(*args: Any, **kwargs: Any) -> Any:
if cls not in instances:
instances[cls] = cls(*args, **kwargs)
return instances[cls]
return get_instance # type: ignore
@singleton
class DatabaseConnection:
"""Singleton database connection."""
def __init__(self, host: str) -> None:
self.host = host
print(f"Connecting to {host}...")
# Only creates one instance
db1 = DatabaseConnection("localhost")
db2 = DatabaseConnection("localhost")
print(db1 is db2) # True - same instance
# Output: Connecting to localhost... (only once)Adding Methods to Classes
from typing import Type, Any
def add_repr(cls: Type[Any]) -> Type[Any]:
"""Add a __repr__ method to a class."""
def __repr__(self: Any) -> str:
attrs = ", ".join(f"{k}={v!r}" for k, v in self.__dict__.items())
return f"{cls.__name__}({attrs})"
cls.__repr__ = __repr__
return cls
@add_repr
class Point:
def __init__(self, x: int, y: int) -> None:
self.x = x
self.y = y
p = Point(10, 20)
print(p) # Point(x=10, y=20)8. functools.wraps - Preserving Metadata
Always use @wraps from functools to preserve the original function's metadata:
from functools import wraps
from typing import Callable, Any
# Without @wraps - loses metadata
def bad_decorator(func: Callable[..., Any]) -> Callable[..., Any]:
def wrapper(*args: Any, **kwargs: Any) -> Any:
return func(*args, **kwargs)
return wrapper
# With @wraps - preserves metadata
def good_decorator(func: Callable[..., Any]) -> Callable[..., Any]:
@wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
return func(*args, **kwargs)
return wrapper
@bad_decorator
def bad_func() -> None:
"""Original docstring."""
pass
@good_decorator
def good_func() -> None:
"""Original docstring."""
pass
print(bad_func.__name__) # "wrapper" ❌
print(good_func.__name__) # "good_func" ✅
print(bad_func.__doc__) # None ❌
print(good_func.__doc__) # "Original docstring." ✅9. Real-World Example: API Rate Limiter
Here's a complete example combining multiple decorator concepts:
import time
from typing import Callable, Any
from functools import wraps
from collections import defaultdict
class RateLimiter:
"""Rate limiter using decorator pattern."""
def __init__(self, max_calls: int, period: float) -> None:
"""Initialize rate limiter.
Args:
max_calls: Maximum calls allowed in period
period: Time period in seconds
"""
self.max_calls = max_calls
self.period = period
self.calls: defaultdict[str, list[float]] = defaultdict(list)
def __call__(self, func: Callable[..., Any]) -> Callable[..., Any]:
"""Make the class instance callable as a decorator."""
@wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
now = time.time()
func_name = func.__name__
# Remove calls outside the time window
self.calls[func_name] = [
call_time for call_time in self.calls[func_name]
if now - call_time < self.period
]
# Check if rate limit exceeded
if len(self.calls[func_name]) >= self.max_calls:
wait_time = self.period - (now - self.calls[func_name][0])
raise RuntimeError(
f"Rate limit exceeded. Try again in {wait_time:.2f}s"
)
# Record this call
self.calls[func_name].append(now)
# Execute function
return func(*args, **kwargs)
return wrapper
# Usage: max 3 calls per 5 seconds
@RateLimiter(max_calls=3, period=5.0)
def fetch_data(url: str) -> str:
"""Simulate API call."""
print(f"Fetching {url}")
return f"Data from {url}"
# First 3 calls succeed
for i in range(3):
print(fetch_data(f"http://api.com/data/{i}"))
# Fourth call raises error
try:
fetch_data("http://api.com/data/3")
except RuntimeError as e:
print(f"Error: {e}")
# Wait 5 seconds, then it works again
time.sleep(5)
print(fetch_data("http://api.com/data/4"))10. Common Decorator Patterns
Authentication Decorator
from typing import Callable, Any
from functools import wraps
def require_auth(func: Callable[..., Any]) -> Callable[..., Any]:
"""Decorator to require authentication."""
@wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
# In real app, check session, token, etc.
user = kwargs.get('user')
if not user or not user.get('authenticated'):
raise PermissionError("Authentication required")
return func(*args, **kwargs)
return wrapper
@require_auth
def view_profile(user: dict[str, Any]) -> str:
"""View user profile."""
return f"Profile: {user['name']}"
# view_profile(user={}) # Raises PermissionError
result = view_profile(user={'name': 'Alice', 'authenticated': True})
print(result) # "Profile: Alice"Caching/Memoization Decorator
from typing import Callable, Any
from functools import wraps
def memoize(func: Callable[..., Any]) -> Callable[..., Any]:
"""Cache function results."""
cache: dict[tuple, Any] = {}
@wraps(func)
def wrapper(*args: Any) -> Any:
if args in cache:
print(f"Returning cached result for {args}")
return cache[args]
result = func(*args)
cache[args] = result
return result
return wrapper
@memoize
def fibonacci(n: int) -> int:
"""Calculate fibonacci number."""
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(10)) # Calculates once
print(fibonacci(10)) # Returns cached result
# Note: Python's functools.lru_cache is a better built-in alternativeDeprecation Warning Decorator
import warnings
from typing import Callable, Any
from functools import wraps
def deprecated(
reason: str = "This function is deprecated"
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
"""Mark a function as deprecated."""
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
@wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
warnings.warn(
f"{func.__name__} is deprecated. {reason}",
category=DeprecationWarning,
stacklevel=2
)
return func(*args, **kwargs)
return wrapper
return decorator
@deprecated(reason="Use new_function() instead")
def old_function() -> str:
"""Old implementation."""
return "Old result"
# Calling this will show a deprecation warning
result = old_function()11. Best Practices
✅ Do's
1. Always Use @wraps
from functools import wraps
def my_decorator(func):
@wraps(func) # ✅ Preserves metadata
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper**2. Accept *args and kwargs
def flexible_decorator(func):
@wraps(func)
def wrapper(*args, **kwargs): # ✅ Works with any function
return func(*args, **kwargs)
return wrapper3. Use Type Hints
from typing import Callable, Any
def typed_decorator(func: Callable[..., Any]) -> Callable[..., Any]:
@wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
return func(*args, **kwargs)
return wrapper4. Document Your Decorators
def my_decorator(func: Callable[..., Any]) -> Callable[..., Any]:
"""
Decorator that does X.
Usage:
@my_decorator
def func():
pass
"""
@wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
return func(*args, **kwargs)
return wrapper❌ Don'ts
1. Don't Forget @wraps
# ❌ Bad - loses function metadata
def bad_decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper2. Don't Use Decorators for Complex Logic
# ❌ Bad - too complex for a decorator
@massive_decorator_with_100_lines
def simple_function():
pass
# ✅ Good - keep decorators simple
@simple_decorator
def function():
# Complex logic here
pass3. Don't Modify Arguments Unless Documented
# ❌ Bad - surprising behavior
def sneaky_decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
args = tuple(str(arg) for arg in args) # Unexpected!
return func(*args, **kwargs)
return wrapper12. Common Pitfalls
Pitfall 1: Order of Decorators Matters
@decorator_a
@decorator_b
def func():
pass
# Equivalent to: func = decorator_a(decorator_b(func))
# decorator_b is applied first, then decorator_aPitfall 2: Decorating Methods Requires self
class MyClass:
@my_decorator
def method(self, x: int) -> int:
# self is passed as first argument to wrapper
return x * 2Pitfall 3: Return Value Must Be Handled
def broken_decorator(func):
def wrapper(*args, **kwargs):
func(*args, **kwargs)
# ❌ Forgot to return result!
return wrapper
# ✅ Always return the result
def good_decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs) # Return result
return wrapperSummary
In this guide, you've learned:
✅ Functions as first-class objects in Python
✅ Closures and how they capture enclosing scope
✅ Creating basic function decorators
✅ Using @ syntax for clean code
✅ Decorators with arguments (nested decorators)
✅ Class decorators for modifying classes
✅ functools.wraps for preserving metadata
✅ Common patterns: timing, logging, retry, auth, caching
✅ Best practices and common pitfalls
Decorators are essential for frameworks like Flask, FastAPI, and Django, and understanding them enables you to write cleaner, more modular code.
Next Steps
Now that you understand decorators, explore related topics:
More Python Deep Dives:
- Type Hints & Typing - Type-safe decorators
- Async Programming - Async decorators
- Python Testing with pytest - Testing decorated functions
Apply Decorators in Web Development:
- FastAPI Learning Roadmap - FastAPI uses decorators extensively
Back to Roadmap:
- Python Learning Roadmap - Complete learning path
- Phase 2: OOP & Advanced Features - Where decorators were introduced
Part of the Python Learning Roadmap series
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.