Decorators

Decorators can feel like magic the first time you see the @ symbol sitting above a function. In reality, they rest on a simple idea that Python embraces fully: functions are just objects, and one function can wrap another to add extra behavior without changing its source code.

Functions Are First-Class Objects

In Python, a function is a value like any other. It can be assigned to a variable, stored in a list, passed as an argument to another function, and returned from a function. This is the foundation that makes decorators possible.

def greet(name):
    return f"Hello, {name}!"

say_hello = greet          # assigned to another variable
print(say_hello("Sonu"))  # Hello, Sonu!

def call_twice(func, value):
    print(func(value))
    print(func(value))

call_twice(greet, "World")  # passed as an argument

What a Decorator Actually Is

A decorator is a function that takes another function as input and returns a new function that usually calls the original one, plus a bit of extra behavior before and/or after it. Nothing about the original function's code changes — the decorator just wraps it.

Writing a Custom Decorator

Here is a decorator that logs when a function starts and finishes, using *args, **kwargs in the inner wrapper so it can accept any combination of arguments the decorated function might take:

def log_call(func):
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        result = func(*args, **kwargs)
        print(f"Finished {func.__name__}")
        return result
    return wrapper

@log_call
def add(a, b):
    return a + b

print(add(2, 3))

The @log_call line above def add(a, b): is exactly equivalent to writing add = log_call(add) right after the function is defined. Every call to add(...) now actually runs wrapper(...), which logs a message, calls the real add, logs again, and returns the result.

A Timing Decorator Example

Decorators are frequently used to measure how long a function takes to run, without cluttering the function itself with timing code:

import time

def timer(func):
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        elapsed = time.time() - start
        print(f"{func.__name__} took {elapsed:.4f} seconds")
        return result
    return wrapper

@timer
def slow_square(n):
    return n * n

slow_square(10)

Preserving Metadata with functools.wraps

One side effect of the wrapping pattern above is that add.__name__ would report "wrapper" instead of "add", since the decorator replaced the original function. The functools.wraps decorator fixes this by copying over the original function's name, docstring, and other metadata:

import functools

def log_call(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

Note: It's easy to forget functools.wraps, but skipping it can make debugging and introspection (help(), documentation tools) confusing, since the wrapped function loses its original identity.

Built-in Decorators You Already Know

Decorators aren't just a custom-code concept — you've likely already used a couple of built-in ones inside classes:

  • @staticmethod marks a method that doesn't need access to the instance (self) or the class; it behaves like a plain function that just happens to live inside a class.
  • @property lets you call a method as if it were a plain attribute, without parentheses, which is useful for computed values.
class Circle:
    def __init__(self, radius):
        self.radius = radius

    @property
    def area(self):
        return 3.14159 * self.radius ** 2

    @staticmethod
    def unit_name():
        return "radius units"

c = Circle(5)
print(c.area)          # accessed like an attribute, no parentheses
print(Circle.unit_name())
Next steps: Head to Regular Expressions to learn how to search and validate text patterns.