Back to blog

Python Decorators: Functions as First-Class Objects

pythondecoratorsfunctionsclosuresdesign-patterns
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:


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))  # 50

This 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())  # 10

Closures 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)  # 10

Logging 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 8

Retry 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 ValueError

6. 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 time

Order 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 alternative

Deprecation 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 wrapper

3. 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 wrapper

4. 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 wrapper

2. 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
    pass

3. 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 wrapper

12. 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_a

Pitfall 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 * 2

Pitfall 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 wrapper

Summary

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:

Apply Decorators in Web Development:

Back to Roadmap:


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.