Lesson 158 min read

Decorators & Closures

Wrap functions in superpowers without touching their code

Functions Are First-Class Citizens

In Python, functions are just objects — like any other value. You can store them in variables, pass them as arguments, and return them from other functions. This might feel weird at first, but it's the foundation for everything in this lesson.

Think of it this way: a function is like a recipe card. You can hand the card to someone else (pass it as an argument), photocopy it (assign to another variable), or even create new recipe cards inside a factory (return a function from a function).

Functions as Objects

# Functions can be assigned to variables
def shout(text):
return text.upper() + "!!!"
yell = shout # No parentheses — we're passing the function itself
print(yell("hello"))
# Functions can be passed as arguments
def apply_twice(func, value):
return func(func(value))
def add_exclamation(text):
return text + "!"
result = apply_twice(add_exclamation, "wow")
print(result) # wow + ! + !
# Functions can be stored in data structures
operations = {
"double": lambda x: x * 2,
"square": lambda x: x ** 2,
"negate": lambda x: -x,
}
for name, func in operations.items():
print(f"{name}(5) = {func(5)}")
Output
HELLO!!!
wow!!
double(5) = 10
square(5) = 25
negate(5) = -5

Closures — Functions That Remember

A closure is a function that "remembers" variables from the place where it was created, even after that place no longer exists. It's like a snowglobe — the little world inside is frozen in time, but you can still look at it.

Closures happen when an inner function references a variable from the outer function's scope. The inner function "closes over" those variables and carries them along.

Closures in Action

# A function that creates customized functions
def make_multiplier(factor):
def multiply(x):
return x * factor # 'factor' is remembered!
return multiply
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(5)) # 10
print(triple(5)) # 15
print(double(100)) # 200
# The closure remembers 'factor' even though make_multiplier finished!
print()
# Practical closure: a counter
def make_counter(start=0):
count = [start] # Using a list so the inner function can modify it
def increment():
count[0] += 1
return count[0]
return increment
page_views = make_counter()
print(f"Visit 1: {page_views()}")
print(f"Visit 2: {page_views()}")
print(f"Visit 3: {page_views()}")
Output
10
15
200

Visit 1: 1
Visit 2: 2
Visit 3: 3

Decorators — The Main Event

A decorator is a function that takes a function, adds some behavior to it, and returns a new enhanced function. It's like gift-wrapping a present — the present inside is unchanged, but the wrapping adds something extra (a bow, a tag, sparkles).

The @decorator syntax is just syntactic sugar. Writing @my_decorator above a function definition is the same as my_func = my_decorator(my_func).

Building a Decorator

import functools
import time
# A simple timer decorator
def timer(func):
@functools.wraps(func) # Preserves the original function's name/docstring
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs) # Call the original function
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.4f}s")
return result
return wrapper
@timer
def slow_greeting(name):
"""Greet someone slowly."""
time.sleep(0.1) # Simulate slow work
return f"Hello, {name}!"
# Using the decorated function
result = slow_greeting("Alice")
print(result)
print(f"Function name: {slow_greeting.__name__}") # Thanks to @wraps
print(f"Docstring: {slow_greeting.__doc__}")
Output
slow_greeting took 0.1003s
Hello, Alice!
Function name: slow_greeting
Docstring: Greet someone slowly.

Real-World Decorator Patterns

Decorators are everywhere in professional Python code. Here are some of the most common patterns:

  • Logging — Track when functions are called and with what arguments
  • Authentication — Check if a user is allowed to call a function
  • Caching/Memoization — Remember results to avoid re-computing
  • Retry logic — Automatically retry failed operations
  • Validation — Check inputs before the function runs

Practical Decorators

import functools
# 1. Logging decorator
def log_call(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
args_str = ", ".join(map(repr, args))
print(f"Calling {func.__name__}({args_str})")
result = func(*args, **kwargs)
print(f" → returned {result!r}")
return result
return wrapper
# 2. Simple memoization (caching) decorator
def memoize(func):
cache = {}
@functools.wraps(func)
def wrapper(*args):
if args not in cache:
cache[args] = func(*args)
return cache[args]
return wrapper
@log_call
def add(a, b):
return a + b
add(3, 4)
print()
@memoize
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
# Without memoize, fib(35) would take forever. With it: instant!
print(f"fib(10) = {fibonacci(10)}")
print(f"fib(35) = {fibonacci(35)}")
print()
# Python has a built-in version!
@functools.lru_cache(maxsize=128)
def factorial(n):
return 1 if n <= 1 else n * factorial(n - 1)
print(f"20! = {factorial(20)}")
Output
Calling add(3, 4)
  → returned 7

fib(10) = 55
fib(35) = 9227465

20! = 2432902008176640000

Decorators with Arguments

Sometimes you want to configure a decorator — like telling it how many times to retry, or what message to log. For this, you need a decorator factory: a function that takes arguments and returns a decorator.

Parameterized Decorators

import functools
def repeat(n):
"""Decorator factory: repeat a function n times."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = None
for _ in range(n):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(3) # repeat(3) returns the actual decorator
def say_hello(name):
print(f"Hello, {name}!")
say_hello("World")
print()
# Stacking decorators — they apply bottom-up
def bold(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return f"<b>{func(*args, **kwargs)}</b>"
return wrapper
def italic(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return f"<i>{func(*args, **kwargs)}</i>"
return wrapper
@bold # Applied second (outer)
@italic # Applied first (inner)
def greet(name):
return f"Hello, {name}"
print(greet("Alice")) # bold(italic(greet))("Alice")
Output
Hello, World!
Hello, World!
Hello, World!

<b><i>Hello, Alice</i></b>
Note: Always use @functools.wraps(func) in your decorator's wrapper function. Without it, the decorated function loses its original name and docstring, which makes debugging a nightmare. It's one line of code that saves hours of confusion.

Quick check

What is a closure?
Lambda, Map & Filter