DEV Community

Cover image for Demystifying Python Decorators, Part 1: The Foundational Concepts
Aaron Rose
Aaron Rose

Posted on

Demystifying Python Decorators, Part 1: The Foundational Concepts

If you've spent any time with Python, you've almost certainly seen the @ symbol above a function definition. This is a decorator, a powerful feature that can seem a bit magical at first. But what if I told you that decorators are just a logical extension of a core Python principle you already know?

Think of a decorator like a gift wrapper for a function. It doesn't change the gift inside (the function's core logic), but it adds a beautiful new layer of functionality on the outside.

In this first part of our series, we'll demystify the core idea behind decorators by understanding the problem they solve and the fundamental concept that makes them possible. By the end, you'll see how the manual decorator pattern we build here leads naturally to Python's elegant @decorator syntax in Part 2.

The Problem: Adding Functionality the Hard Way

Imagine you have a few simple functions and you want to time how long each one takes to run. A common, but flawed, approach is to add the timing code directly to each function.

import time

def greet(name):
    # Timing code
    start = time.time()
    time.sleep(1) # Simulate some work
    print(f"Hello, {name}!")
    # Timing code
    end = time.time()
    print(f"greet took {end - start:.2f} seconds to run.")

def calculate_sum(a, b):
    # Timing code
    start = time.time()
    time.sleep(0.5) # Simulate some work
    result = a + b
    # Timing code
    end = time.time()
    print(f"calculate_sum took {end - start:.2f} seconds to run.")
    return result

greet("Alice")
calculate_sum(5, 7)
Enter fullscreen mode Exit fullscreen mode

This works, but it's terrible practice. We're repeating the exact same timing logic, violating the DRY (Don't Repeat Yourself) principle. If we need to change how we measure time, we have to edit every function. There has to be a better way to reuse this code.

The Solution: Functions as First-Class Objects

The secret to solving this problem lies in one of Python's most powerful features: functions are first-class objects. This means you can treat functions just like any other variable in Python. Specifically, you can assign them to variables, pass them as arguments to other functions, and return them from functions just like you would with strings, numbers, or lists.

This is the key that unlocks decorators. Let's use this idea to create a timer function that takes another function as an argument, adds timing to it, and returns a new function with the added capability.

import time

def timer(func):
    def wrapper(*args, **kwargs):
        start = time.time()
        try:
            result = func(*args, **kwargs)
        except Exception as e:
            end = time.time()
            print(f"{func.__name__} took {end - start:.2f} seconds to run (with error).")
            raise e
        end = time.time()
        print(f"{func.__name__} took {end - start:.2f} seconds to run.")
        return result
    return wrapper

# Let's try it out!
def greet(name):
    time.sleep(1)
    print(f"Hello, {name}!")
    # Note: All timing logic has been removed and is now handled by the decorator!

# Manually "decorate" the function by reassigning it
greet = timer(greet)
Enter fullscreen mode Exit fullscreen mode

Here, we pass the original greet function to timer(). The timer function returns the new wrapper function, and we reassign the name greet to this new function. Now, when we call greet("Bob"), we are actually calling wrapper("Bob").

# Now, when we call greet(), it runs the timing code first!
greet("Bob")
Enter fullscreen mode Exit fullscreen mode

Let's break down what's happening in the timer function:

  1. Accept a function: timer(func) takes any function as its parameter
  2. Define a wrapper: Inside timer, we create a new function called wrapper that will replace the original
  3. Execute the original: The wrapper calls the original function with func(*args, **kwargs), preserving all arguments
  4. Add our enhancement: We wrap the function call with timing logic
  5. Return the wrapper: timer returns this new enhanced function

The *args and **kwargs syntax ensures our wrapper can handle any function, regardless of how many arguments it takes or whether they're positional or keyword arguments.

Why This Matters

Congratulations! You've just created a decorator. The timer function is a decorator because it "decorates" or enhances another function's behavior without modifying its source code.

This pattern is incredibly powerful because it lets you:

  • Add functionality to existing functions without changing their code
  • Apply the same enhancement to multiple functions
  • Keep your core business logic clean and separate from cross-cutting concerns like timing, logging, or authentication

In our next article, we'll introduce the clean and elegant @ syntax that makes this entire process much simpler and more readable. While this manual pattern works perfectly, it has a small limitation: it slightly obscures the original function's metadata (like its name and docstring). In Part 2, we'll not only learn the clean @ syntax but also how to use functools.wraps to fix this issue and create perfect decorators. You'll see how @timer above a function definition is just syntactic sugar for the manual approach we've learned here.


Ready to write more Pythonic code? Follow for practical Python insights that will transform your programming style.


Aaron Rose is a software engineer and technology writer at tech-reader.blog and the author of Think Like a Genius.

Top comments (0)