Python Decorators Explained: From Beginner to Pro (With Real Examples)
Jan 21, 2026 9 Min Read 85 Views
(Last Updated)
Have you ever needed to add functionality to your Python functions without actually changing their code? A decorator in Python offers exactly this flexibility, allowing you to modify or extend how your functions behave while keeping your codebase clean and maintainable.
Essentially, Python decorators are functions that take another function as input and return a new function with enhanced capabilities. They work because Python treats functions as first-class citizens, meaning functions can be passed as arguments, returned from other functions, and assigned to variables.
In this beginner-friendly guide, you’ll learn what Python decorators are, how they work, and how to create and use them in your own projects. From basic syntax to advanced techniques, we’ll explore practical examples that demonstrate why decorators have become such an essential tool for Python developers. Let’s begin!
Quick Answer:
A Python decorator is a reusable way to add extra behavior to a function without modifying its original code, using functions and closures to wrap and extend functionality.
Table of contents
- What is a Python Decorator?
- Why use decorators?
- Basic decorator syntax with the @ symbol
- Understanding the Building Blocks
- 1) Functions as first-class objects
- 2) Nested functions and closures
- 3) How closures enable decorators
- Creating Your First Decorator
- 1) Step-by-step example
- 2) Adding behavior before and after a function
- 3) Using *args and **kwargs for flexibility
- Advanced Decorator Techniques
- 1) Decorators with arguments
- 2) Chaining multiple decorators
- 3) Preserving metadata with functools.wraps
- 4) Class-based decorators
- Real-World Uses of Python Decorators
- 1) Logging function calls
- 2) Authentication and access control
- 3) Caching with functools.lru_cache
- 4) Retry logic for network calls
- 5) Input validation
- Concluding Thoughts…
- FAQs
- Q1. What is a Python decorator, and how does it work?
- Q2. What are some common use cases for Python decorators?
- Q3. How do you create a basic Python decorator?
- Q4. Can decorators accept arguments?
- Q5. How can you preserve a function's metadata when using decorators?
What is a Python Decorator?
In its most fundamental form, a decorator in Python is a function that takes another function as an argument and returns a modified version of it. This wrapped function adds extra functionality before, after, or around the original function’s execution. In essence, decorators provide a clean way to extend and enhance behavior.
The decorator pattern follows a simple principle: accepting a function, adding some functionality, and returning the enhanced function. This approach embodies the concept of functions as “first-class objects” in Python, meaning functions can be passed as arguments, returned from other functions, and assigned to variables.
Consider this scenario: you have multiple functions in your code that need similar logging capabilities. Rather than repeating the same logging code across all functions, you can create a decorator that handles this task and apply it wherever needed.
Why use decorators?
Decorators offer several compelling advantages that make them worth mastering:
- Code cleanliness: They help separate concerns, making your code more organized and understandable
- Reusability: Apply the same functionality across multiple functions without duplicating code
- Non-invasive modifications: Extend the function behavior without altering the source code
- Readability: Make your code more expressive and self-explanatory
Furthermore, decorators simplify common programming tasks such as:
- Logging function calls and execution times
- Authentication and access control
- Caching function results
- Input validation
- Debugging support
Many popular Python frameworks like Flask and Django heavily rely on decorators for routing, authentication, and other core functionality. Mastering decorators ultimately helps you write more elegant and maintainable code.
Basic decorator syntax with the @ symbol
The standard way to use a decorator in Python involves the @ symbol, sometimes called the “pie syntax.” This special notation provides a concise way to apply decorators to functions.
Here’s a simple example:
def make_pretty(func):
def inner():
print(“I got decorated”)
func()
return inner
@make_pretty
def ordinary():
print(“I am ordinary”)
ordinary() # Calls the decorated function
This code outputs:
I got decorated
I am ordinary
The @make_pretty line above the function definition is equivalent to writing ordinary = make_pretty(ordinary). It tells Python to pass the ordinary function through the make_pretty decorator, which wraps it with additional functionality.
For decorators that need to handle functions with any number of arguments, you can use *args and **kwargs:
def decorator_name(func):
def wrapper(*args, **kwargs):
print(“Before execution”)
result = func(*args, **kwargs)
print(“After execution”)
return result
return wrapper
@decorator_name
def add(a, b):
return a + b
print(add(5, 3)) # Works with any arguments
This pattern enables you to create flexible decorators that work with any function, regardless of its parameters.
Understanding the Building Blocks
To understand how decorators work in Python, you need to grasp three core programming concepts that serve as their foundation. These building blocks are what make the decorator pattern possible and practical in everyday coding.
1) Functions as first-class objects
In Python, functions are treated as first-class objects, which means they can be handled like any other data type (integers, strings, etc.). Specifically, this gives functions some powerful capabilities:
- They can be assigned to variables
- They can be passed as arguments to other functions
- They can be returned from other functions
- They can be stored in data structures like lists and dictionaries
Here’s a simple example demonstrating these properties:
def say_hello(name):
return f”Hello {name}”
# Assigning a function to a variable
greeting_function = say_hello
# Passing a function as an argument
def greet_bob(greeter_func):
return greeter_func(“Bob”)
result = greet_bob(say_hello) # Output: “Hello Bob”
This flexibility allows you to write higher-order functions (functions that operate on other functions), which form the basis for decorators.
2) Nested functions and closures
Python permits defining functions inside other functions, creating what’s called nested functions. The inner function exists only within the scope of the outer function:
def outer_function(x):
# This is a local variable in the outer function
message = f”Value is {x}”
def inner_function():
# Inner function can access the outer function’s variables
print(message)
# Call the inner function
inner_function()
outer_function(10) # Output: “Value is 10”
A closure occurs when a nested function captures and remembers the values from its enclosing scope, even after the outer function has finished executing. For a closure to exist, three conditions must be met:
- There must be a nested function
- The nested function must reference variables from the outer function
- The outer function must return the nested function
For example:
def multiplier(factor):
def multiply_by_factor(number):
return number * factor # Using ‘factor’ from outer scope
return multiply_by_factor
double = multiplier(2)
print(double(5)) # Output: 10
In this case, double becomes a closure that “remembers” the factor value (2) from its creation, despite multiplier() having completed execution.
3) How closures enable decorators
Closures provide the mechanism that allows decorators to work their magic. Since decorators need to modify the behavior of functions without changing their code, closures offer the perfect solution.
When you create a decorator, you’re essentially:
- Defining an outer function (the decorator) that takes a function as an argument
- Creating an inner function (the wrapper) that adds functionality
- Making the inner function access both the input function and any variables from the decorator
- Returning the inner function as a closure
This structure allows the decorator to maintain state between calls and modify the behavior of the wrapped function. Consider this simple example:
def log_calls(func):
def wrapper(*args, **kwargs):
print(f”Calling {func.__name__}”)
result = func(*args, **kwargs)
print(f”Finished calling {func.__name__}”)
return result
return wrapper
@log_calls
def add(a, b):
return a + b
add(3, 5) # The function is now decorated with logging
Here, the wrapper function is a closure that captures the original func and adds behavior before and after calling it. Moreover, the wrapper can handle any arguments passed to the original function through *args and **kwargs.
Understanding these building blocks demonstrates why Python is well-suited for the decorator pattern and provides the foundation for creating more advanced decorators in your code.
To add a quick touch of insight, here are a couple of lesser-known facts about Python decorators that often surprise developers:
Decorators were inspired by functional programming: The idea of wrapping functions to extend behavior comes from functional programming concepts, which Python adopted early to support cleaner and more expressive code.
Decorators run at function definition time: When Python reads a decorated function, the decorator is applied immediately—not when the function is called—making decorators powerful but also important to use carefully.
These facts show why decorators feel both elegant and magical, yet require a solid understanding to use effectively.
Creating Your First Decorator
Now that you understand the theory, let’s roll up our sleeves and create your first Python decorator. This hands-on approach will solidify your understanding of how decorators work in practice.
1) Step-by-step example
Creating a basic decorator involves several key steps. First, define a function that takes another function as its argument. Inside this outer function, define an inner wrapper function that adds functionality and returns the result:
def 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
To apply this decorator, you can use the @ symbol above your function definition:
@decorator
def say_whee():
print(“Whee!”)
# This is equivalent to:
# say_whee = decorator(say_whee)
When you call say_whee(), you’ll actually be calling the wrapper() function, which executes additional code before and after your original function.
2) Adding behavior before and after a function
One of the most common uses of decorators is adding behavior that runs before and after a function call. This pattern is useful for logging, timing functions, or setting up preconditions.
For instance, you can create a decorator that logs when a function starts and finishes:
def log_execution(func):
def wrapper():
print(“Before execution”)
result = func()
print(“After execution”)
return result
return wrapper
@log_execution
def add(a, b):
return a + b
Unfortunately, this decorator won’t work with our add() function because wrapper() doesn’t accept any arguments yet.
3) Using *args and **kwargs for flexibility
To create decorators that work with any function regardless of its parameters, use *args and **kwargs in your wrapper function:
def do_twice(func):
def wrapper_do_twice(*args, **kwargs):
func(*args, **kwargs)
func(*args, **kwargs)
return wrapper_do_twice
The *args parameter collects all positional arguments into a tuple, whereas **kwargs gathers all keyword arguments into a dictionary. This makes your decorator universally applicable:
def decorator_name(func):
def wrapper(*args, **kwargs):
print(“Before execution”)
result = func(*args, **kwargs)
print(“After execution”)
return result
return wrapper
@decorator_name
def add(a, b):
return a + b
print(add(5, 3)) # Works with arguments!
# Output:
# Before execution
# After execution
# 8
This pattern ensures your decorator works with any function, regardless of how many arguments it takes or whether they’re positional or keyword arguments. Additionally, if your decorated function returns a value, make sure your wrapper function also returns that value to preserve the original function’s behavior.
By following these steps, you’ve created a fully functional Python decorator that can enhance any function without modifying its original code!
Advanced Decorator Techniques
After mastering the basics, it’s time to take your Python decorator skills to the next level with more sophisticated techniques that solve real-world programming challenges.
1) Decorators with arguments
Sometimes you need decorators that accept their own parameters. Unlike simple decorators, these require an additional function layer:
def log_decorator_with_prefix(prefix):
def log_decorator(func):
def wrapper(*args, **kwargs):
print(f”{prefix} Executing {func.__name__}”)
result = func(*args, **kwargs)
return result
return wrapper
return log_decorator
@log_decorator_with_prefix(“[INFO]”)
def say_hello(name):
print(f”Hello, {name}!”)
In this pattern, the outermost function (log_decorator_with_prefix) creates a decorator factory that accepts arguments. The middle function is the actual decorator, which returns the innermost wrapper function that surrounds the original function call.
2) Chaining multiple decorators
Python allows you to stack multiple decorators on a single function. Each decorator processes the function in order from bottom to top:
def square_decorator(func):
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return result ** 2
return wrapper
def double_decorator(func):
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return result * 2
return wrapper
@double_decorator
@square_decorator
def add(a, b):
return a + b
result = add(2, 3) # Output: 20
Looking at the execution flow: First square_decorator squares the result (25), then double_decorator doubles it (50). The order matters! Reversing these decorators would yield a different result.
3) Preserving metadata with functools.wraps
One issue with decorators is that they replace the original function’s metadata like name and docstring. Consequently, this can break introspection tools and documentation generators.
The solution comes from the functools module:
import functools
def log_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f”Executing {func.__name__}”)
result = func(*args, **kwargs)
return result
return wrapper
functools.wraps preserves important attributes of the original function:
- __name__ (function name)
- __doc__ (docstring)
- __module__ (module name)
- Other metadata
This becomes critical when debugging or using tools that rely on these attributes.
4) Class-based decorators
Although most decorators are functions, they can alternatively be implemented as classes:
class TimerDecorator:
def __init__(self, func):
self.func = func
functools.update_wrapper(self, func)
def __call__(self, *args, **kwargs):
import time
start_time = time.perf_counter()
result = self.func(*args, **kwargs)
elapsed_time = time.perf_counter() – start_time
print(f”Elapsed time: {elapsed_time:.6f} seconds”)
return result
@TimerDecorator
def slow_function():
import time
time.sleep(2)
Class-based decorators work through:
- The __init__ method capturing the decorated function
- The __call__ method makes the class instance callable like a function
- functools.update_wrapper preserving metadata
This approach offers additional flexibility through class attributes that can maintain state between calls.
These advanced decorator techniques significantly expand what you can achieve with Python’s decoration pattern, making your code more modular and expressive.
Real-World Uses of Python Decorators
Beyond theory, Python decorators shine in practical applications where they solve common programming challenges with elegant solutions.
1) Logging function calls
Decorators excel at implementing logging features across an entire project. Instead of adding repetitive logging code to every function, you can create a single @log_it decorator that records function execution details. This decorator can track when functions are called, capture arguments, log execution times, and record results to a file.
def log_it(func):
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
with open(“activity.log”, “a”) as f:
f.write(f”{func.__name__} was called at {datetime.now()}\n”)
return result
return wrapper
2) Authentication and access control
Web frameworks like Flask and Django frequently employ decorators to handle authentication. For instance, the @login_required decorator ensures users are authenticated before accessing protected routes.
This approach ensures security without cluttering your core functionality:
@app.route(“/bookapi/books/<int:book_id>”)
@login_required
def add_book():
return book[book_id]
3) Caching with functools.lru_cache
The @lru_cache decorator from Python’s standard library automatically caches function results to avoid expensive recalculations. This built-in decorator is perfect for operations like downloading web content or computing Fibonacci numbers:
@lru_cache(maxsize=32)
def get_pep(num):
‘Retrieve text of a Python Enhancement Proposal.’
resource = f’https://peps.python.org/pep-{num:04d}’
with urllib.request.urlopen(resource) as s:
return s.read()
The cache dramatically improves performance—in one example, reducing runtime from 40 seconds to under 1 millisecond.
4) Retry logic for network calls
Decorators offer elegant solutions for handling unreliable network operations by implementing retry mechanisms. When API calls fail due to temporary issues, a retry decorator can automatically attempt the operation again:
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, max=10))
def fetch_data(url):
response = requests.get(url, timeout=5)
response.raise_for_status()
return response.json()
This approach prevents application crashes during temporary service disruptions.
5) Input validation
Validating function inputs is yet another ideal use case. The @validate_call decorator from Pydantic verifies function arguments against their type annotations:
@validate_call
def repeat(s: str, count: int, *, separator: bytes = b”) -> bytes:
b = s.encode()
return separator.join(b for _ in range(count))
This technique saves considerable boilerplate code that would otherwise be needed for manual validation.
Master Python the right way with HCL GUVI’s Python Course, where complex concepts like decorators are broken down through real-world examples and hands-on practice. Perfect for beginners and intermediate learners, it helps you write cleaner, reusable, and production-ready Python code with confidence.
Concluding Thoughts…
Python decorators stand as one of the language’s most elegant features, allowing you to enhance functions without modifying their original code. Throughout this guide, you’ve learned how decorators leverage Python’s treatment of functions as first-class objects, enabling powerful code transformation through simple syntax.
After mastering the fundamental building blocks—functions as first-class objects, nested functions, and closures—you can create decorators that handle any situation. The practical examples showed how decorators streamline common tasks such as logging, authentication, caching, and input validation.
As you continue your Python journey, decorators will prove increasingly valuable—whether you’re building web applications, processing data, or creating utilities. They represent Python’s philosophy of providing simple, readable solutions to complex problems.
FAQs
Q1. What is a Python decorator, and how does it work?
A Python decorator is a function that takes another function as input and returns a modified version of it. It allows you to add functionality to existing functions without changing their source code. Decorators work by wrapping the original function with additional code, which can be executed before, after, or around the original function’s execution.
Q2. What are some common use cases for Python decorators?
Python decorators are commonly used for logging function calls, implementing authentication and access control, caching function results, adding retry logic for network operations, and input validation. They help in separating cross-cutting concerns and make code more modular and reusable.
Q3. How do you create a basic Python decorator?
To create a basic Python decorator, define a function that takes another function as an argument. Inside this outer function, define an inner wrapper function that adds the desired functionality. The wrapper function should call the original function and return its result. Finally, return the wrapper function from the outer function.
Q4. Can decorators accept arguments?
Yes, decorators can accept arguments. To create a decorator that takes arguments, you need to add an extra layer of function nesting. The outermost function accepts the decorator arguments, the middle function serves as the actual decorator, and the innermost function is the wrapper that surrounds the original function call.
Q5. How can you preserve a function’s metadata when using decorators?
To preserve a function’s metadata (such as its name, docstring, and module) when using decorators, you can use the @functools.wraps decorator from the Python standard library. Apply this decorator to your wrapper function inside the decorator definition to copy the metadata from the original function to the decorated version.



Did you enjoy this article?