Learn by reading through in order

Decorators — Adding Behavior to Functions with @

Walk through how Python decorators add behavior to functions with @, from the basics through arguments — all runnable in your browser.

By the end of the previous article on lambdas, you'd seen the main ways to treat functions as values. To wrap up, let's pin down decorators — the dedicated syntax for layering extra behavior on top of a function.

What Is a Decorator?

A decorator is a way to add behavior before and after a function without changing the function itself. Things like "log the call," "time the run," or "cache the result" — shared behavior you want to layer onto lots of functions — can live in one place.

The syntax is just one line above the function definition: @decorator_name. Python reads that as "the same as func = decorator_name(func)".

@ Means "Run This Function Through That Function"
@loggerdef greet():greet = logger(greet)expands to
Writing @logger above def greet(): makes Python run greet = logger(greet) internally, replacing greet with a new function wrapped by logger.
# The decorator itself (a higher-order function that takes and returns a function)
def logger(func):
    def wrapper():
        print("=== start ===")
        func()                       # call the original function
        print("=== end ===")
    return wrapper

# Caller side: just add @
@logger
def greet(): # → logger(greet)
    print("Hello")

greet()
# === start ===
# Hello
# === end ===

# Internally equivalent to:
# def greet():
#     print("Hello")
# greet = logger(greet)

The Basic Decorator — Wrapping a Function with wrapper

The skeleton of a decorator is a 3-step shape: the outer function takes func, the inner function (conventionally wrapper) calls func(), and you return wrapper. The wrapper that keeps func available as it runs is exactly a closure.

Whatever you write before and after func() runs every time the decorated function is called.

def logger(func):
    def wrapper(*args, **kwargs):
        print(f"[LOG] running {func.__name__}")
        result = func(*args, **kwargs)
        print(f"[LOG] {func.__name__} done")
        return result
    return wrapper

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

print(greet("Alice"))
# [LOG] running greet
# [LOG] greet done
# Hello, Alice
How the logger Decorator Wraps a Function
Module (Global Namespace)
  • greet is replaced by the wrapper function
  • The original greet body lives on as func inside wrapper
logger(func)'s Frame
  • func holds the original greet
  • Builds wrapper inside and returns it
wrapper (a closure that remembers func)
  • Runs pre → func() → post in order
  • From the outside, this is the new greet
What @logger does is swap the global greet for a different function called wrapper. The original greet body is called as func from inside wrapper.

Build a bracket decorator that prints greetings before and after a function and layer it on.

① Define def bracket(func):, and inside it def wrapper():. Have wrapper run print("--- start ---")func()print("--- end ---") in order, and have the outer return wrapper.

② Define def introduce(): decorated with @bracket, with just print("I am Alice") in the body.

③ Call introduce() and confirm the body is sandwiched between --- start --- / --- end ---.

(When the answer is correct, the explanation will appear.)

Python Editor

Run code to see output

Pass Any Arguments Through with *args / **kwargs

So far, wrapper took no arguments. When you want to decorate functions that do take arguments, use *args / **kwargs to accept any arguments as-is and forward them straight to `func`.

That turns the decorator into a generic one that works with any function signature. It handles add(2, 3) (positional) and add(2, 3, name="ABC") (keyword) with the same decorator.

def log_call(func):
    def wrapper(*args, **kwargs):
        print(f"call: args={args}, kwargs={kwargs}")
        result = func(*args, **kwargs)   # unpack and forward here too
        print(f"result: {result}")
        return result                    # don't forget to return it
    return wrapper

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

print(add(2, 3))
# call: args=(2, 3), kwargs={}
# result: 5
# 5

print(add(2, b=3))
# call: args=(2,), kwargs={'b': 3}
# result: 5
# 5
How *args / **kwargs Pass Arguments Straight Through
add(2, b=3)wrapper(*args, **kwargs)args=(2,), kwargs={'b': 3}unpack and forwardas func(*args, **kwargs)add(a, b)original add(a=2, b=3)returns 5wrapper returnsit as-isreceivepre-stepunpackcomputesend result back

Don't Forget to return the Result

If wrapper only writes result = func(...) and forgets the return, the decorated function's return value silently turns into None. The classic accident is finding add(2, 3) quietly returning None — when you write a decorator, treat return result as part of the same muscle memory.

Build a log_call decorator that prints each call and apply it to a 2-argument function.

① Define def log_call(func):, and inside it def wrapper(*args, **kwargs):.

② In wrapper, print f"call: args={args}, kwargs={kwargs}", then result = func(*args, **kwargs), then return result.

③ Have the outer return wrapper.

④ Define def multiply(a, b): return a * b decorated with @log_call. Call print(multiply(4, 5)) and print(multiply(2, b=10)) and confirm the log appears followed by the return value.

Python Editor

Run code to see output
QUIZ

Knowledge Check

Answer each question one by one.

Q1Which of the following has the same meaning as putting @logger above def greet(): ...?

Q2What does this code print?
def deco(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs) * 2
return wrapper
@deco
def plus(a, b):
return a + b
print(plus(3, 4))