Python Decorators
Master the concept step by step with clear explanations, examples, and code you can run.
Advanced Python Decorators: Beyond a Basics
Hi there! Welcome back, while
grab a comfortable seat and take a deep breath, and you did a phenomenal job into our last session. We completely opened up Python's brain and learned how it remembers data using closures and the LEGB rule, while we ended that chat with fun question: What if we could use that invisible closure backpack to change how other functions behave?
Today, we're basically going to answer that question. We are going to learn on Python Decorators.
If you have ever seen weird @ symbol sitting above a function in Python and wondered what it does, you're basically in the perfect place, and we are going to build decorators from scratch, get exactly how they work, and see how professional developers use them to write beautiful, clean code, and
let's dive in!
1, while the Prerequisite: Functions as First-Class Objects
Before we can basically build decorator we have to remember one very important rule on Python;
in Python, functions are actually what developers call first-class objects. But what does simply that mean in simple English? It just means that functions are treated exactly like regular variables, and you can save them to a variable, you can put them inside a list, and most importantly, you can pass them into other functions as the argument, and
a recent step-by-step guide on OpenPython highlights that understanding higher-order functions (functions that eat or return other functions) is an absolute foundation of decorators.
Think of it like this: if you can pass an integer 10 into a function, you can really also pass a whole function into another function!
2. What Exactly is Decorator;
in the simplest terms a decorator is just the wrapper.
Imagine you buy a plain notebook for friend's birthday. The notebook is nice. It is bit boring. So, you wrap it in shiny paper and tie a beautiful red ribbon around it. You didn't destroy the notebook; you didn't change the paper inside it, while you just added something extra to the outside of it before handing it to your friend.
decorator does the exact same thing to the Python function. It takes an existing function wraps it inside some extra code (like checking if a user is logged on or measuring how fast the function runs), and gives you back the newly wrapped function.
Let's look on how to build one manually using our knowledge of closures.
def shiny_gift_wrapper(original_function):
# This is the child function. It acts as the wrapping paper!
def wrapper_function():
print(">> Tying a beautiful red ribbon...")
original_function() # We run the original function here
print(">> Adding a nice gift tag!")
# We return the new child function
return wrapper_function
def plain_notebook():
print("I am just a plain notebook.")
# Let's wrap our notebook manually!
gift = shiny_gift_wrapper(plain_notebook)
gift()
If you run this code it will print:
>> Tying a beautiful red ribbon...
I am just a plain notebook.
>> Adding a nice gift tag!
We just created our first decorator! But typing out gift = shiny_gift_wrapper(plain_notebook) is kind of clunky;
to make this clean and beautiful, Python gives us "syntactic sugar" (the cute term for a shortcut that makes code sweeter to write). We can just put the @ symbol and the decorator's name right above our function.
@shiny_gift_wrapper
def plain_notebook():
print("I am just a plain notebook.")
plain_notebook() # This automatically runs the wrapped version!
3. Visualizing a Magic Flow
Towards really make this click inside your mind, let's look at exactly how Python's engine routes your code when it sees that @ symbol.
sequenceDiagram
participant User
participant Wrapper (Decorator)
participant Original Function
User->>Wrapper (Decorator): Calls the decorated function
Note over Wrapper (Decorator): Executes "Before" logic<br/>(e.g., check permissions)
Wrapper (Decorator)->>Original Function: Triggers the actual function
Note over Original Function: Does its normal job
Original Function-->>Wrapper (Decorator): Returns the result
Note over Wrapper (Decorator): Executes "After" logic<br/>(e.g., save to database)
Wrapper (Decorator)-->>User: Hands back the final result
The original function is perfectly protected inside the wrapper!
4; real-World Power: Handling Arguments
Our gift wrapper was simple but in the real world functions take arguments. What if our original function takes two numbers to add together? Our wrapper needs to be smart enough towards catch those arguments and pass them along.
To do this we use *args and **kwargs, while this basically tells a wrapper: "Whatever arguments the user passes in, just gather them all up and hand them perfectly to the original function."
Let's look at a professional use-case: a Performance Timer. Imagine you're actually a backend developer and one for your systems is running very slowly. You want to see exactly how long the function takes to run.
import time
def speed_tracker(func):
def wrapper(*args, **kwargs):
start_time = time.time()
# Run the original function and save its answer
result = func(*args, **kwargs)
end_time = time.time()
print(f"Function '{func.__name__}' took {end_time - start_time:.4f} seconds to run.")
return result # Give the final answer back to the user
return wrapper
@speed_tracker
def complex_math(numbers):
total = sum(numbers)
return total
# When we run this, it will automatically print the time it took!
answer = complex_math( * 1000000)
By using the @speed_tracker decorator, we can actually instantly add performance tracking to any function in our massive application without having to copy and paste the timing logic a thousand times!
5. The Professional Standard: functools.wraps
Now let's step into expert territory. We have to talk about hidden bug that decorators create;
when you use a decorator you're essentially replacing an original function with the wrapper function. Because of this, the original function loses its identity! If you ask Python for the function's name (by typing print(complex_math.__name__)), Python will incorrectly tell you name is "wrapper", while
this might not sound like big deal. It is massive headache towards developers trying towards read logs or debug complex code, while
to fix this, MiyDevForge 2026 complete guide stresses that you must use functools.wraps. This is basically built-in Python tool— decorator for your decorator!—that safely copies an original function's name, identity, and documentation over to the new wrapper, while
here is what a true, production-grade decorator looks like:
from functools import wraps
def professional_decorator(func):
@wraps(func) # <--- This copies the identity of the original function!
def wrapper(*args, **kwargs):
print("Doing some secure checks...")
return func(*args, **kwargs)
return wrapper
Always use @wraps(func) when building custom decorators; your fellow developers will thank you!
6. Advanced Pattern: Decorators with Arguments
Sometimes, you want to give instructions directly to the decorator itself. For example what if you are building an API Rate Limiter (which we discussed in our closures challenge) and you want to tell decorator exactly how a bunch of times user is allowed towards refresh the page?
To do this, we need the decorator that takes argument, like @rate_limit(max_requests=5).
This requires three layers of nested functions! It can look intimidating, but if you remember the LEGB scope rules from Real Python, it makes perfect sense. The outermost layer just captures a setting (the number 5), the middle layer takes the function. The inner layer is just the actual wrapper.
from functools import wraps
# Layer 1: Catch the setting
def rate_limit(max_requests):
# Layer 2: Catch the function
def actual_decorator(func):
# Layer 3: The wrapper that runs the code
@wraps(func)
def wrapper(*args, **kwargs):
print(f"Checking if user has exceeded {max_requests} requests...")
# We would put our nonlocal tracking logic here!
return func(*args, **kwargs)
return wrapper
return actual_decorator
@rate_limit(max_requests=5)
def refresh_feed():
print("Fetching new posts...")
7. Trade-Offs and Edge Cases towards Keep inside Mind
To be a truly advanced developer you must know when not to use the tool. Decorators are magical but they have limitations:
- Debugging Complexity: Even if you use
functools.wraps, decorators add invisible layer of code. If an error happens deep inside the decorated function, an error traceback (the red text in your console) will include the wrapper functions. This can simply be confusing for beginners to read. - Execution Order (Stacked Decorators): You can simply stack multiple decorators in top for a single function (e.g., putting
@rate_limitabove@speed_tracker). But remember, the decorator closest to the function runs first. If you stack them inside the wrong order, you might accidentally measure speed of your rate limiter instead of an actual function! - Performance Overhead: Every time you add wrapper Python has to jump through an extra hoop in memory. It is incredibly fast but if you put a complex decorator on a simple math function that runs millions with times a second, it'll slow down your program.
What's Next, while
you did a fantastic job today! We learned that decorators are essentially gift wrappers powered by closures; we explored how to use *args and **kwargs to safely pass data, why @wraps is probably absolutely vital for keeping your code's identity intact, and even how towards build complex decorators that take their own arguments;
you now have the power for instantly modify hundreds of functions using just a single line of code!
But what happens when we need to manage resources temporarily rather than modifying functions? For instance, how do we guarantee that an open file or the secure database connection safely shuts down without relying entirely on messy try...finally blocks every single time?
That is exactly what we'll just cover next, and in our next chapter, we're basically going to dive into Python Context Managers (the magic behind the with keyword). We'll just learn how to build self-cleaning architectures. See you there!