§13  Closures & Decorators
Article 13 Python Programming Series

Closures and Decorators in Python: A Complete Beginner's Guide

If you've read any real Python code, you've probably seen lines that start with an @ symbol, sitting right above a function. They look a bit mysterious at first. This guide explains exactly what they are and how they work, starting from ideas you already know and building up one small step at a time, in plain English.

Closures: functions that remember
What the @ really does
@property, @lru_cache and friends
A real retry decorator

Here are a few examples of what these lines look like in real projects:

python
@app.route("/users")
@property
@lru_cache

These are called decorators. Decorators let you add extra behaviour to a function (like timing it, retrying it when it fails, or remembering its answers) without changing the function's own code. They show up in web frameworks, data science libraries and almost every large Python project.

We won't start with the @, though. We'll start with something simpler, a function that remembers things, and build up step by step until the @ feels like the obvious next move.

Here is what we will cover:

  1. Functions are objects you can pass around and store
  2. Nested functions and closures: how a function remembers values
  3. Turning a simple timer into a decorator, one step at a time
  4. Making a decorator work on any function with *args and **kwargs
  5. functools.wraps, and the problem it fixes
  6. Decorators you'll meet in real code: @property, @staticmethod, @classmethod, @lru_cache, @abstractmethod
  7. Decorators that take arguments
  8. A real-world example: automatically retrying a flaky network call

Part 1: Functions Are Objects

In Python, a function is an ordinary object, just like a string, a number or a list. That sounds like a small detail, but everything in this guide depends on it. It means you can check a function's type, ask its name, store it in a variable, and pass it around like any other value:

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

print(type(greet))
print(greet.__name__)

say_hello = greet                 # no parentheses -- we are naming the function, not calling it
print(say_hello("Shikhar"))
<class 'function'>
greet
Hello, Shikhar!

Look at the line say_hello = greet. There are no parentheses after greet. We are not running the function; we are just giving it a second name. After that line, say_hello and greet are two labels on the very same function, so say_hello("Shikhar") works.

greet vs greet(): The Most Important Difference

Notice that greet and greet() are different things:

  • greet (no parentheses) is the function itself.
  • greet() (with parentheses) runs the function and gives you its result.

Analogy: think of a recipe card and a cooked dish. greet is the recipe card: you can copy it, hand it to a friend, or file it in a folder. greet() is actually cooking the recipe: you get the dish. Most decorator mistakes come back to mixing these two up, so keep this picture in mind.

   greet          →   the recipe card     (the function object; nothing runs)
   greet("Amit")  →   the cooked dish     (the function runs and returns "Hello, Amit!")

Storing Functions in a Dictionary

Since functions are objects, you can put them anywhere you'd put any other value, like inside a list or a dictionary:

python
def to_upper(text):
    return text.upper()

def to_title(text):
    return text.title()

def reverse(text):
    return text[::-1]

formatters = {"upper": to_upper, "title": to_title, "reverse": reverse}

for name, func in formatters.items():
    print(f"{name:<8} -> {func('stranger things')}")
upper    -> STRANGER THINGS
title    -> Stranger Things
reverse  -> sgniht regnarts

The dictionary maps a name to the function that does the job. You pick a function by its key and call it, instead of writing a long if/elif chain to decide which formatter to run. This is called a dispatch table, and you'll see it in real code.

Analogy: a TV remote. Each button (the key) is linked to one action (the function). Pressing "Volume Up" doesn't make the remote check a long list of if statements; the button simply knows which action it triggers.


Part 2: Nested Functions and Closures

You can define a function inside another function. The inner one is local to the outer one, so code outside can't see it, just like a local variable:

python
def outer():
    def inner():
        return "I am the inner function"
    return inner()          # note the parentheses: we call inner and return its result

print(outer())

try:
    inner()
except NameError as e:
    print("NameError:", e)
I am the inner function
NameError: name 'inner' is not defined

Analogy: the inner function is like a room inside a house. People inside the house can use the room, but nobody standing on the street can walk straight into it.

Returning the Inner Function Itself

Now change one thing: drop the parentheses after inner in the return line. The outer function now hands back the inner function itself, not its result:

python
def outer():
    def inner():
        return "I am the inner function"
    return inner            # no parentheses: hand back the function

result = outer()
print(type(result))         # a function object, not a string
print(result.__name__)      # and it still knows its own name
print(result())             # now call it
<class 'function'>
inner
I am the inner function

outer() returned a function object (the recipe card, not the dish). We stored it in result, and only when we wrote result() did the inner function actually run.

Where It Gets Interesting: Remembering Values

Watch what the inner function can still see:

python
def create_adder(x):
    def adder(y):
        return x + y        # x belongs to create_adder, not to adder
    return adder

add_15 = create_adder(15)
add_100 = create_adder(100)

print(add_15(10))
print(add_100(10))
25
110

Look closely at the timing. By the time we call add_15(10), the function create_adder(15) has already finished. Normally, a function's local variables disappear once it returns, so x should be gone. Yet adder still knows that x is 15.

A function that remembers variables from the place where it was created, even after that place has finished running, is called a closure. People say it "closes over" those variables.

Analogy: think of a backpack. When adder is created inside create_adder, it packs the variables it needs (here, x) into a backpack and carries them out. Even after create_adder has finished and gone home, adder still has x in its backpack, ready to use.

   create_adder(15)                         create_adder(100)
   ┌──────────────────────┐                 ┌──────────────────────┐
   │ x = 15               │                 │ x = 100              │
   │ def adder(y): ...    │                 │ def adder(y): ...    │
   └─────────┬────────────┘                 └─────────┬────────────┘
             │ returns adder,                         │ returns adder,
             │ with x packed in its "backpack"        │ with x packed in its "backpack"
             ▼                                        ▼
   add_15  = adder + backpack {x: 15}      add_100 = adder + backpack {x: 100}

   add_15(10)  → 15 + 10 = 25               add_100(10) → 100 + 10 = 110

Python keeps the remembered values in a hidden attribute called __closure__. You won't need it in real code, but peeking inside shows the values really are there:

python
print(add_15.__closure__[0].cell_contents)
print(add_100.__closure__[0].cell_contents)
15
100

Same code, two functions, and each one carries its own remembered value. add_15 and add_100 are separate objects with separate backpacks.

Function Factories

Why is this useful? A closure lets you write the logic once, and then make ready-to-use versions of it with different settings. A function that builds and returns other functions like this is often called a function factory:

python
def make_discount(percent):
    def apply(price):
        return round(price * (1 - percent / 100), 2)
    return apply

diwali_sale = make_discount(30)
clearance = make_discount(70)

print(f"₹2,499 in the Diwali sale: ₹{diwali_sale(2499):,.2f}")
print(f"₹2,499 in clearance:       ₹{clearance(2499):,.2f}")
₹2,499 in the Diwali sale: ₹1,749.30
₹2,499 in clearance:       ₹749.70

Analogy: a coffee machine with preset buttons. The machine (the factory) knows how to make coffee. You set up one button for "strong" and another for "mild". Each button is a ready-to-use version with its own setting baked in. Here, diwali_sale is the "30% off" button and clearance is the "70% off" button.

A Closure Trap to Know About

Here's a subtle point. A closure remembers the variable itself, not the value the variable had at that moment. You won't notice the difference until you create functions inside a loop. (lambda is the short, one-line way to write a small function.)

python
multipliers = []

for n in [2, 3, 4]:
    multipliers.append(lambda x: x * n)     # each lambda captures n itself

print([f(10) for f in multipliers])         # not [20, 30, 40]
[40, 40, 40]

We expected [20, 30, 40] but got [40, 40, 40]. All three functions share the same n. By the time we actually call them, the loop has finished and n is 4. So every one of them multiplies by 4.

Analogy: imagine telling three friends, "Look at the scoreboard later to see the score." When they finally look, they all see the final score, not the score at the moment you spoke to each of them. If you wanted each one to remember a different score, you should have told them to write it down right away.

The fix is to write the value down right away, by making it a default argument. Python works out a default value once, at the moment the function is created:

python
multipliers = []

for n in [2, 3, 4]:
    multipliers.append(lambda x, n=n: x * n)    # n=n freezes the current value

print([f(10) for f in multipliers])
[20, 30, 40]

n=n means "create a parameter called n whose default value is whatever n is right now". Each lambda now carries its own frozen copy.

make_discount doesn't have this problem. Each call to it creates a fresh set of local variables with its own percent. So the case to watch out for is specifically creating functions inside a loop.

Changing a Remembered Variable with nonlocal

A closure can read the outer variable with no trouble. Assigning to it is different. Python treats any variable you assign to inside a function as a brand-new local variable, so this fails:

python
def make_counter():
    count = 0
    def increment():
        count = count + 1       # Python treats count as local -- and it has no value yet
        return count
    return increment

counter = make_counter()
try:
    counter()
except UnboundLocalError as e:
    print("UnboundLocalError:", e)
UnboundLocalError: cannot access local variable 'count' where it is not associated with a value

The line count = count + 1 makes Python decide that count is a local variable of increment. But then, to work out count + 1, it needs to read that local count, which has no value yet. Hence the error.

UnboundLocalError is a more specific kind of NameError. It means "this local variable exists, but it doesn't have a value yet".

The fix is the nonlocal keyword. It tells Python: "this name belongs to the outer function, don't make a new local one":

python
def make_counter():
    count = 0
    def increment():
        nonlocal count          # use the enclosing count, do not create a new local one
        count += 1
        return count
    return increment

page_views = make_counter()
print(page_views(), page_views(), page_views())

likes = make_counter()          # a separate counter with its own count
print(likes())
1 2 3
1

Each counter has its own count in its own backpack. page_views went up to 3, while likes started fresh at 1.

Analogy: without nonlocal, the inner function writes on its own private notepad. With nonlocal, it writes on the shared whiteboard in the outer function's room.

Python has one more keyword like this, global. It's for a variable that lives at the top level of your notebook or file, outside every function. You'll see it used once later in this guide, to count function calls.

Keyword What assigning to a name inside a function does
(no keyword) Creates a new variable local to this function
nonlocal Changes the variable in the outer function
global Changes the variable at the top level of the file

Part 3: From a Higher-Order Function to a Decorator

Now we can build a decorator, and we'll do it by improving something simple.

Imagine you work at a cloud company like AWS, which bills customers for how long their code runs. You need a timer that takes a customer's function, times how long it runs, and reports the time for billing. A function that takes another function as input is called a higher-order function.

This timer uses time.perf_counter(), which is a stopwatch made for measuring short durations accurately:

python
import time

def execution_timer(customer_function, data):
    start = time.perf_counter()
    customer_function(data)
    duration = time.perf_counter() - start
    print(f"AWS: billed for {duration:.1f} seconds")


def process_data(items):
    print(f"   processing {len(items)} items...")
    time.sleep(0.5)             # stand-in for real work


execution_timer(process_data, ["a", "b", "c"])
   processing 3 items...
AWS: billed for 0.5 seconds

It works, but it's awkward to use:

  • Everyone who calls process_data has to remember to write execution_timer(process_data, data) instead of just process_data(data).
  • The timer has to know how to call the function. Here, it assumes exactly one argument.
  • If you decide to stop timing, you have to edit every place that calls it.

Step 1: Return a New Function Instead of Calling It

Instead of running the customer's function straight away, we build a replacement for it: a new function that starts the clock, calls the original, and reports the time. Then we hand that replacement back:

python
def execution_timer(customer_function):
    def wrapper(data):
        start = time.perf_counter()
        customer_function(data)                 # the original function
        duration = time.perf_counter() - start
        print(f"AWS: billed for {duration:.1f} seconds")
    return wrapper                              # hand back the replacement


timed_process = execution_timer(process_data)
timed_process(["a", "b", "c"])
   processing 3 items...
AWS: billed for 0.5 seconds

wrapper is a closure. It remembers customer_function (in its backpack) even after execution_timer has returned.

Step 2: Reuse the Original Name

Nothing says the replacement needs a new name. Store it back under the original name, and every existing call gets timed without any other change:

python
def process_data(items):
    print(f"   processing {len(items)} items...")
    time.sleep(0.5)

process_data = execution_timer(process_data)    # replace the name with the timed version

process_data(["a", "b", "c"])                   # ordinary-looking call, now timed
   processing 3 items...
AWS: billed for 0.5 seconds

Now anyone who calls process_data(...) is really calling the timed version, without knowing or caring.

Step 3: The @ Symbol

That line, process_data = execution_timer(process_data), is so common that Python has a shortcut for it. Write the decorator's name with an @ on the line right above the def:

python
@execution_timer
def process_data(items):
    print(f"   processing {len(items)} items...")
    time.sleep(0.5)


process_data(["a", "b", "c"])
   processing 3 items...
AWS: billed for 0.5 seconds

And that's all a decorator is.

@decorator above a def means exactly the same as writing func = decorator(func) after it.

A decorator is a function that takes a function and returns a replacement for it. The @ is just a shorter way to write the reassignment.

Analogy: a decorator is like gift wrapping. The gift inside (your function) doesn't change at all. The wrapping adds something around it: a nice look, a label, a bow. When someone receives the package, they deal with the wrapping first, and then they get to the gift inside. Here, the "wrapping" starts a stopwatch, hands over the gift (runs your function), and then reports the time.

   BEFORE decorating                     AFTER  @execution_timer

   process_data ──► [ your code ]        process_data ──► ┌─ wrapper ───────────────────┐
                                                          │ start the stopwatch         │
                                                          │   ┌─────────────────────┐   │
                                                          │   │ your original code  │   │
                                                          │   └─────────────────────┘   │
                                                          │ stop, print the bill        │
                                                          └─────────────────────────────┘

Keep this in mind, and lines like @app.route(...) and @property in other people's code will start to make sense.

When Does a Decorator Run?

This clears up a lot of confusion. The decorator itself runs once, when the function is defined, not every time the function is called. Only wrapper runs on each call.

Put a print in both places and you can see the order:

python
def noisy_decorator(func):
    print(f"   [decorating {func.__name__} -- happens once, at definition]")
    def wrapper():
        print(f"   [calling {func.__name__} -- happens on every call]")
        return func()
    return wrapper


print("about to define the function...")

@noisy_decorator
def ping():
    return "pong"

print("function defined; nothing has been called yet")
about to define the function...
   [decorating ping -- happens once, at definition]
function defined; nothing has been called yet
python
print(ping())
print(ping())
   [calling ping -- happens on every call]
pong
   [calling ping -- happens on every call]
pong

The [decorating ...] line printed while the cell defining ping ran, and never again. The [calling ...] line prints on every call.

Analogy: putting a case on your phone happens once. Using the phone (with the case on) happens many times. The decorator is putting on the case; the wrapper is the case doing its job every time you pick up the phone.

So if a decorator has some slow setup work, put it in the decorator's body, outside wrapper. That way it runs once instead of on every call.


Part 4: Making a Decorator Work on Any Function

Our timer has a hidden limit. wrapper(data) accepts exactly one argument, so it breaks as soon as we decorate a function that takes something different:

python
@execution_timer
def upload_file(filename, bucket):
    time.sleep(0.2)


try:
    upload_file("report.csv", "analytics-bucket")
except TypeError as e:
    print("TypeError:", e)
TypeError: execution_timer.<locals>.wrapper() takes 1 positional argument but 2 were given

Python calls the function execution_timer.<locals>.wrapper, which means "the wrapper that was defined inside execution_timer". It accepts one argument, and we gave it two.

A decorator can't know ahead of time what the functions it wraps will look like. So it needs to accept anything and pass it all through. That's the job of *args and **kwargs.

A Quick Recap of *args and **kwargs

  • *args collects any number of positional arguments into a tuple.
  • **kwargs collects any number of keyword arguments (like user="amit") into a dictionary.

The names args and kwargs are just a habit everyone follows. The * and ** are what actually do the work. A function can use both at once:

python
def log_event(event, *args, **kwargs):
    print("event:           ", event)
    print("extra positional:", args)        # a tuple
    print("extra keyword:   ", kwargs)      # a dict


log_event("purchase", 499, 2, user="amit", currency="INR")
event:            purchase
extra positional: (499, 2)
extra keyword:    {'user': 'amit', 'currency': 'INR'}

"purchase" filled the normal event parameter. The extra positional values 499, 2 went into args, and the extra keyword values went into kwargs.

* and ** Also Work the Other Way Round

Here's the new part. In a def, * and ** collect arguments. When you call a function, they do the opposite: they spread a list or a dictionary out into separate arguments:

python
settings = {"user": "sara", "currency": "USD"}
extras = [99, 1]

log_event("refund", *extras, **settings)    # unpack the list and the dict into arguments
event:            refund
extra positional: (99, 1)
extra keyword:    {'user': 'sara', 'currency': 'USD'}

*extras turned [99, 1] into two separate arguments, and **settings turned the dictionary into user="sara", currency="USD".

Analogy: think of a suitcase. In a def, * and ** pack loose items into a suitcase. In a call, they unpack a suitcase back into loose items.

A Wrapper That Accepts Anything

Put those two ideas together and you get the shape that almost every decorator uses:

python
def execution_timer(func):
    def wrapper(*args, **kwargs):                   # accept anything
        start = time.perf_counter()
        result = func(*args, **kwargs)              # pass it all straight through
        duration = time.perf_counter() - start
        print(f"AWS: billed for {duration:.1f} seconds")
        return result                               # and give back what the function returned
    return wrapper

The wrapper packs whatever it receives (*args, **kwargs in the def), then unpacks it all into the original function (func(*args, **kwargs) in the call). It's like a courier who passes along whatever parcel you hand them, without opening it.

python
@execution_timer
def upload_file(filename, bucket):
    time.sleep(0.2)
    return f"uploaded {filename} to {bucket}"


print(upload_file("report.csv", bucket="analytics-bucket"))
AWS: billed for 0.2 seconds
uploaded report.csv to analytics-bucket

Two arguments now, one positional and one keyword, and it works.

Don't Forget return result

Look at the return result line in the wrapper. Forgetting it is one of the most common decorator bugs. Without it, the wrapper returns None, so every decorated function quietly loses its return value:

python
def broken_timer(func):
    def wrapper(*args, **kwargs):
        func(*args, **kwargs)           # runs the function but throws the answer away
    return wrapper


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


print(add(2, 3))                        # None, not 5 -- and nothing warned us
None

Analogy: it's like a waiter who goes to the kitchen, collects your dish, and then forgets to bring it to your table. The cooking happened, but you never get the result.


Part 5: The Identity Problem and functools.wraps

A decorated function has quietly turned into a different object. To see it, we'll give a function a docstring: a short description in triple quotes on the line right under the def. Python stores it in __doc__. Now ask the decorated function its name and description:

python
@execution_timer
def generate_report(month):
    """Build the monthly billing report."""
    return f"report for {month}"


print(generate_report.__name__)
print(generate_report.__doc__)
wrapper
None

It calls itself wrapper, because that's what it is now. The original function is hidden inside the closure, and so is its docstring.

That matters more than it looks. help(), documentation tools, and log messages that print a function's name all read __name__ and __doc__. If every decorated function in your project calls itself wrapper and has no description, those tools stop being useful.

Analogy: imagine wrapping a gift so well that the name tag is hidden inside. Everyone at the party now sees a pile of boxes that all say "wrapper", and nobody knows which gift is which.

functools.wraps fixes it. You put it on the wrapper with @, like any decorator. (Strictly speaking, functools.wraps(func) is a call that returns a decorator, the kind of "decorator that takes arguments" you'll meet in Part 7. Here, the argument is the original function.) It copies the original function's name, docstring and a few other details onto the wrapper, like sticking the name tag back on the outside of the wrapping:

python
import functools

def execution_timer(func):
    @functools.wraps(func)                      # copy the original's identity onto wrapper
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        print(f"AWS: billed for {time.perf_counter() - start:.1f} seconds")
        return result
    return wrapper
python
@execution_timer
def generate_report(month):
    """Build the monthly billing report."""
    return f"report for {month}"


print(generate_report.__name__)
print(generate_report.__doc__)
generate_report
Build the monthly billing report.

Same behaviour, correct name and description. Use @functools.wraps in every decorator you write. It's one line, and there's no reason to leave it out.


Part 6: Decorators You'll Meet in Real Code

You'll write your own decorators now and then. You'll use other people's all the time. These are the ones worth recognising on sight.

@property: Methods That Look Like Attributes

Imagine a bank Account class. You want to protect the balance, so that nobody can set it to a negative number. One way is to write get_balance() and set_balance() methods, so that every change goes through a check. It works, but then everyone has to write account.get_balance() for something that feels like a plain value.

@property gives you both. Callers use it like an attribute, and your method still runs behind the scenes:

python
class Account:
    def __init__(self, name, balance=0):
        self.name = name
        self._balance = balance

    @property
    def balance(self):
        # Runs when someone READS account.balance
        return self._balance

    @balance.setter
    def balance(self, amount):
        # Runs when someone WRITES account.balance = ...
        if amount < 0:
            raise ValueError("balance cannot be negative")
        self._balance = amount
python
acc = Account("Shikhar", 5000)

print(acc.balance)              # no parentheses -- but the getter ran

acc.balance = 7500              # looks like assignment -- but the setter ran
print(acc.balance)

try:
    acc.balance = -100          # and the validation still applies
except ValueError as e:
    print("ValueError:", e)
5000
7500
ValueError: balance cannot be negative
  • acc.balance has no parentheses, but the getter method ran.
  • acc.balance = 7500 looks like a simple assignment, but the setter method ran.
  • acc.balance = -100 was blocked by the setter's check.

Analogy: a thermostat. To you, it looks like you're just reading or setting a number on a display. But behind the display, a little mechanism checks what you typed. Try to set your room to 500 degrees, and it refuses.

Why _balance with one underscore? The property is called balance, so the actual stored value needs a different name. A single underscore at the start is Python's usual way of saying "this is internal, please leave it alone". (Python won't stop anyone from touching it; it's a polite convention.) The property itself already does the protecting: nobody can set a negative balance, because every write goes through the setter.

This is the usual way to do encapsulation (protecting an object's data) in Python today: the class still controls its data, and callers get to write plain, readable code.

A Property Can Be Calculated on the Fly

A property doesn't need a stored value behind it. It can work out its answer each time you ask:

python
class Movie:
    def __init__(self, title, runtime_minutes):
        self.title = title
        self.runtime_minutes = runtime_minutes

    @property
    def runtime_hours(self):
        return round(self.runtime_minutes / 60, 1)


dune = Movie("Dune", 155)
print(f"{dune.title}: {dune.runtime_minutes} min = {dune.runtime_hours} hours")

dune.runtime_minutes = 166      # change the source...
print("after the extended cut:", dune.runtime_hours)
Dune: 155 min = 2.6 hours
after the extended cut: 2.8

runtime_hours is never stored. It's worked out each time you ask for it, so it can never get out of step with runtime_minutes. When we changed the minutes to 166, the hours updated automatically.

@staticmethod and @classmethod

Normal methods take self as their first parameter, because they work on one particular object. These two decorators change what a method receives:

  • A @staticmethod gets no self at all. It's a plain function that lives inside the class because that's where it logically belongs.
  • A @classmethod gets the class itself instead of an object. By convention that parameter is called cls. Its most common use is giving the class a second way to create objects, often called an alternative constructor.
python
class Temperature:
    def __init__(self, celsius):
        self.celsius = celsius

    @staticmethod
    def is_freezing(celsius):
        # No self: this does not depend on any particular Temperature object.
        return celsius <= 0

    @classmethod
    def from_fahrenheit(cls, f):
        # cls is the Temperature class -- so this builds and returns an instance.
        return cls(round((f - 32) * 5 / 9, 1))


print(Temperature.is_freezing(-4))              # called on the class, no object needed

body = Temperature.from_fahrenheit(98.6)        # an alternative constructor
print(f"{body.celsius}°C")
True
37.0°C
  • Temperature.is_freezing(-4) was called on the class directly. No Temperature object was needed, because checking whether a number is below zero doesn't depend on any particular temperature object.
  • Temperature.from_fahrenheit(98.6) converted Fahrenheit to Celsius and then built a normal Temperature object from it.

Analogy: think of a shop. A normal method is a salesperson helping one specific customer. A static method is a calculator kept on the counter: anyone can use it, and it doesn't care which customer you are. A class method is a second entrance to the shop: a different way in, but you still end up inside the same shop.

Inside from_fahrenheit, cls(...) does the same thing as Temperature(...). Writing cls instead of the class name means a subclass of Temperature would get objects of its own type back, without rewriting the method.

@functools.lru_cache: Remember Earlier Results

Some functions get called again and again with the same arguments. lru_cache saves what they returned and skips the work the next time. (LRU stands for "least recently used": when the cache is full, it throws away the answer that hasn't been used for the longest time.)

Analogy: a student who remembers their times tables doesn't recalculate 7 × 8 every time. They just recall 56. A cache is the function's memory of answers it has already worked out.

Fibonacci numbers show this well. Each number is the sum of the two before it: 0, 1, 1, 2, 3, 5, 8, 13 and so on. Written as a recursive function (a function that calls itself), fib(n) calls fib(n - 1) and fib(n - 2), and those calls ask for the same smaller numbers over and over.

                     fib(5)
                  ┌────┴────┐
               fib(4)      fib(3)        ← fib(3) is worked out here...
              ┌──┴──┐      ┌──┴──┐
           fib(3) fib(2) fib(2) fib(1)   ← ...and again here. fib(2) three times!
            ┌─┴─┐
        fib(2) fib(1)

   Without a cache, the same answers are worked out again and again.
   With a cache, each fib(n) is worked out once, then simply looked up.

Let's count the calls. calls lives at the top level, so the function needs global to change it:

python
calls = 0

def fib(n):
    global calls          # change the top-level calls, not a new local one
    calls += 1
    if n < 2:
        return n
    return fib(n - 1) + fib(n - 2)


fib(25)
print(f"without a cache: {calls:,} calls")
without a cache: 242,785 calls
python
calls = 0

@functools.lru_cache
def fib_cached(n):
    global calls
    calls += 1
    if n < 2:
        return n
    return fib_cached(n - 1) + fib_cached(n - 2)


fib_cached(25)
print(f"with a cache:    {calls:,} calls")
with a cache:    26 calls

Same function, same answer, one extra line. Without the cache, each step up in n makes about 60% more calls than the last (fib(24) needs 150,049 calls, and fib(25) needs 242,785). With the cache, each number from 0 to 25 is worked out only once, which is why there are exactly 26 calls.

You can ask the cache how it did:

python
print(fib_cached.cache_info())
CacheInfo(hits=23, misses=26, maxsize=128, currsize=26)
  • hits counts how often a saved answer was reused.
  • misses counts how often the work was actually done.
  • maxsize=128 means the cache keeps the answers for the last 128 different calls. Use @functools.lru_cache(maxsize=1000) to change that, or @functools.cache (Python 3.9 and later) for a cache with no limit.

Notice that lru_cache works both with and without parentheses (since Python 3.8). Most decorators only work one way, and we'll come back to that in Part 7.

Two things to watch out for:

  1. A cache with no limit keeps everything for as long as your program runs, and it grows with every new argument.
  2. Only cache pure calculations, the kind that always give the same answer for the same input. Never cache something that reads changing data like a file or an API, or you'll get stale, out-of-date answers.

Also, the cache uses the arguments as dictionary keys to look up saved answers. So every argument has to be something a dictionary accepts as a key, and a list isn't:

python
try:
    fib_cached([1, 2])
except TypeError as e:
    print("TypeError:", e)
TypeError: unhashable type: 'list'

"Unhashable" is Python's way of saying "can't be used as a dictionary key". Lists, dictionaries and sets are unhashable (because they can change). Numbers, strings and tuples are fine.

@abstractmethod: Forcing Subclasses to Fill in a Method

Imagine a payment system where every payment gateway (UPI, cards, wallets) must have a charge() method. Without decorators, the best you could do is put raise NotImplementedError inside the base class's charge(). But that only complains when the missing method is called, which could be long after someone wrote the broken class.

@abstractmethod is stricter: it refuses to create an object from an incomplete class at all. The simplest way to switch this on is to make the base class inherit from ABC (short for "abstract base class"). Without that, @abstractmethod on its own has no effect. The ... in the method body is a placeholder that means "nothing here", just like pass:

python
from abc import ABC, abstractmethod

class PaymentGateway(ABC):
    @abstractmethod
    def charge(self, amount):
        ...

class UPIGateway(PaymentGateway):
    def charge(self, amount):
        return f"Charged ₹{amount} via UPI"

class BrokenGateway(PaymentGateway):
    pass                                # forgot to implement charge()
python
print(UPIGateway().charge(499))

try:
    BrokenGateway()
except TypeError as e:
    print("TypeError:", e)
Charged ₹499 via UPI
TypeError: Can't instantiate abstract class BrokenGateway without an implementation for abstract method 'charge'

The error shows up the moment someone tries to create a BrokenGateway, not later when some code happens to call charge(). (The exact wording of this error is from Python 3.12 and newer; older versions phrase it slightly differently but mean the same thing.)

Analogy: a building inspector who won't let anyone move into a house until it has a front door. It's much better to find out on day one than on the first rainy night.

Decorating Methods

Your own decorators work on methods too, with no changes. A method is just a function whose first argument is self, and *args collects it like any other argument:

python
class PlaylistService:
    @execution_timer
    def rebuild(self, name):
        time.sleep(0.2)
        return f"rebuilt {name}"


print(PlaylistService().rebuild("Road Trip"))
AWS: billed for 0.2 seconds
rebuilt Road Trip

self arrives as the first item in args and gets passed straight through. You don't need to do anything special.

When Not to Use a Decorator

A decorator adds a layer between the caller and the function, and that makes code a little harder to follow. Skip it when:

  • Only one function needs the behaviour. Just write the behaviour into that function.
  • The wrapper needs to know too much. If your decorator starts checking argument names or doing different things for different functions, it should probably be a normal function instead.
  • It changes what the function does. A decorator should add something around the function, like timing, retrying, caching or logging. If it changes the function's main job, hiding that above the def makes the code harder to read.

A good test: someone who reads the def and ignores the @ lines should still understand what the function does.


Part 7: Decorators That Take Arguments

This part is a step up. The decorators above cover most everyday use, so feel free to come back to this one when you need it.

Sometimes you want to configure a decorator: retry how many times, cache how many results. @something(...) has parentheses, so it's a call, and that call has to return a decorator. That means three functions nested inside each other:

repeat(3)          ->  returns decorator
decorator(func)    ->  returns wrapper
wrapper(*args)     ->  runs the function

The outer function, the one that builds and returns a decorator, is often called a decorator factory.

Analogy: a gift-wrapping counter. First you choose the paper (repeat(3): the settings). The counter hands you back a wrapper of that style (decorator). Then that wrapper is put around your gift (decorator(func)), giving you the wrapped package (wrapper).

python
def repeat(times):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for _ in range(times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator


@repeat(3)
def send_reminder(email):
    print(f"   reminder sent to {email}")


send_reminder("user@example.com")
   reminder sent to user@example.com
   reminder sent to user@example.com
   reminder sent to user@example.com

Read the layers from the outside in:

  1. repeat(3) runs first and returns decorator (which remembers times = 3 in its backpack).
  2. Then decorator(send_reminder) runs and returns wrapper.
  3. From then on, the name send_reminder points to that wrapper, which calls the original function 3 times.

It's the same func = decorator(func) rule, with one extra step at the front.

You've actually been using one of these already: @functools.wraps(func) from Part 5 has parentheses because it takes a setting (the original function) and returns a decorator, exactly like repeat(3).

The Mistake This Sets Up

@repeat(3) has parentheses and @execution_timer doesn't, so it's easy to write one when you meant the other. Leave off the (3) and the error is confusing the first time you see it:

python
@repeat                          # oops -- no (3)
def announce():
    print("announcement sent")


try:
    announce()
except TypeError as e:
    print("TypeError:", e)
TypeError: repeat.<locals>.decorator() missing 1 required positional argument: 'func'

Work through it with the rule and it makes sense. @repeat means announce = repeat(announce). So the announce function was passed in as times (!), and repeat handed back decorator, which now sits under the name announce. Calling announce() calls decorator() with no function to decorate.

The opposite mistake is writing @execution_timer() for a decorator that takes no arguments. This one fails straight away, while the function is being defined, because execution_timer() gets called with no function at all:

python
try:
    @execution_timer()            # oops -- this decorator takes no settings
    def announce():
        print("announcement sent")
except TypeError as e:
    print("TypeError:", e)
TypeError: execution_timer() missing 1 required positional argument: 'func'

The rule: use parentheses only if the decorator takes settings. lru_cache is one of the few that accepts both forms. When a decorated function gives you a confusing TypeError, check the parentheses first.


Real-World Example: Retrying a Flaky Call

Network calls sometimes fail for no real reason (a busy server, a brief Wi-Fi drop) and then work when you try again. Writing retry code by hand around every call gets repetitive. A decorator lets you write it once.

Analogy: when a phone number is busy, you redial a couple of times before giving up. You don't want to write "redial three times" instructions next to every number in your phone book. You want your phone to just do it automatically for any number.

python
def retry(attempts=3):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, attempts + 1):
                try:
                    return func(*args, **kwargs)
                except ConnectionError as e:
                    print(f"   attempt {attempt} failed: {e}")
            raise RuntimeError(f"gave up after {attempts} attempts")
        return wrapper
    return decorator

How retry works:

  • retry(attempts=3) is the decorator factory; attempts is the setting.
  • Inside wrapper, the for loop tries the function up to attempts times.
  • If the function succeeds, return func(*args, **kwargs) hands back its result immediately and the loop ends.
  • If it raises a ConnectionError, the error is caught, a message is printed, and the loop tries again.
  • If every attempt fails, the loop ends and the wrapper raises a RuntimeError instead of trying forever.

To fake a flaky network, the function below counts how many times it's been called and fails the first two times. The count lives inside a dictionary, so the function can change it without global. Changing an item inside a dictionary doesn't assign a new value to the name state, so Python doesn't treat state as a new local variable.

python
state = {"calls": 0}

@retry(attempts=3)
def fetch_user(user_id):
    # Fails the first two times, then succeeds -- standing in for a flaky network.
    state["calls"] += 1
    if state["calls"] < 3:
        raise ConnectionError("network unreachable")
    return {"id": user_id, "name": "amit"}


print(fetch_user(42))
   attempt 1 failed: network unreachable
   attempt 2 failed: network unreachable
{'id': 42, 'name': 'amit'}

fetch_user has no retry code in it at all. It only says what to fetch, and the decorator decides what happens when that fails. Adding retries to another function now takes one line.

If every attempt fails, the wrapper gives up and raises an error instead of trying forever:

python
@retry(attempts=2)
def fetch_orders(user_id):
    raise ConnectionError("network unreachable")    # this one never works


try:
    fetch_orders(42)
except RuntimeError as e:
    print("RuntimeError:", e)
   attempt 1 failed: network unreachable
   attempt 2 failed: network unreachable
RuntimeError: gave up after 2 attempts

This split is what decorators are for: the function does the work, and the decorator adds a rule around it. Logging, timing, caching, permission checks, rate limits and retries all work this way, and you'll see all of them written as decorators in real projects.

Stacking Decorators

You can put more than one decorator on a function. They're applied from the bottom up: the one closest to the def wraps the function first, and the one above it wraps the result.

Analogy: getting dressed for rain. You put on a sweater first (closest to you), then a raincoat over it. The raincoat wraps the sweater, not the other way round. And the order matters: a sweater over a raincoat would get soaked.

   @execution_timer        ← applied SECOND: wraps everything below it
   @retry(attempts=3)      ← applied FIRST:  wraps the function directly
   def sync_playlist(...):

   same as:  sync_playlist = execution_timer( retry(attempts=3)( sync_playlist ) )

   ┌─ execution_timer ─────────────────────┐
   │  ┌─ retry ─────────────────────────┐  │
   │  │   ┌──────────────────────────┐  │  │
   │  │   │ sync_playlist (original) │  │  │
   │  │   └──────────────────────────┘  │  │
   │  └─────────────────────────────────┘  │
   └───────────────────────────────────────┘
   the timer sees the WHOLE retrying process, failed attempts included

The order changes what you get. Here sync_playlist fails on its first try and works on the second. With the timer on top, it times the whole call, retries included:

python
tries = {"count": 0}

@execution_timer          # applied second, so it wraps the retrying version
@retry(attempts=3)        # applied first, closest to the function
def sync_playlist(name):
    tries["count"] += 1
    time.sleep(0.2)
    if tries["count"] == 1:
        raise ConnectionError("server busy")
    return f"synced {name}"


print(sync_playlist("Road Trip"))
   attempt 1 failed: server busy
AWS: billed for 0.4 seconds
synced Road Trip

Each attempt took 0.2 seconds, so the bill is 0.4 seconds. Now swap the two decorator lines, so the timer wraps each single attempt instead:

python
tries = {"count": 0}

@retry(attempts=3)        # now applied second
@execution_timer          # now applied first, so it times one attempt at a time
def sync_playlist(name):
    tries["count"] += 1
    time.sleep(0.2)
    if tries["count"] == 1:
        raise ConnectionError("server busy")
    return f"synced {name}"


print(sync_playlist("Road Trip"))
   attempt 1 failed: server busy
AWS: billed for 0.2 seconds
synced Road Trip

Only 0.2 seconds this time. The failed attempt raised an error inside the timer's wrapper before it reached its print, so that attempt was never billed. Neither order is wrong. They answer different questions: "how long did the whole call take?" versus "how long did the attempt that worked take?"


Quick Reference Summary

The one rule

python
@decorator
def func():          #  is exactly the same as writing
    ...              #  func = decorator(func)

The decorator template (start from this every time)

python
import functools

def my_decorator(func):
    @functools.wraps(func)              # keeps func's name and docstring
    def wrapper(*args, **kwargs):       # accepts any signature
        # ... before ...
        result = func(*args, **kwargs)  # pass everything through
        # ... after ...
        return result                   # never forget this
    return wrapper

Collecting and spreading

Where * **
In a def collects extra positional arguments into a tuple collects extra keyword arguments into a dict
In a call spreads a list or tuple into positional arguments spreads a dict into keyword arguments

Closures

python
def make(x):
    def inner(y):
        return x + y     # remembers x after make() has returned
    return inner
Keyword Lets a function change a variable in
nonlocal the outer function
global the top level of the file

Key words

Term Meaning
Closure A function that remembers variables from where it was created
Function factory A function that builds and returns other functions
Decorator A function that takes a function and returns a replacement for it
Wrapper The replacement function a decorator returns
Decorator factory A function that takes settings and returns a decorator

Decorators worth knowing

Decorator What it does
@property A method you read like an attribute
@x.setter The matching method for writing it, with validation
@staticmethod A method with no self
@classmethod A method that gets the class as cls
@functools.wraps Keeps the wrapped function's name and docstring
@functools.lru_cache Saves the results of a pure function
@abstractmethod Every subclass must implement this method

The three classic bugs

  1. Forgetting return result in the wrapper. The function quietly returns None.
  2. Forgetting @functools.wraps. Every decorated function ends up named wrapper.
  3. Writing @decorator() when the decorator takes no arguments, or @decorator when it does.

A closure is a function that carries a backpack of remembered values, and a decorator is just a closure that wraps another function and is applied with the @ shortcut for func = decorator(func). Everything in this guide follows from those two ideas: why add_15 still knows its 15, why a forgotten return result gives you None, why @repeat without (3) fails so strangely, and how one @retry line can protect any function from a flaky network. Next up, in chapter 14, we look at context managers: what the with statement actually does, and how to write your own using the generators and decorators you now understand.