DEV Community

Cover image for 🪄 Scopes, Closures, and Decorators in Python: A Deep Dive Adventure
Anik Sikder
Anik Sikder

Posted on

🪄 Scopes, Closures, and Decorators in Python: A Deep Dive Adventure

Most Python tutorials explain what scopes, closures, and decorators are. But let’s be honest… they often feel like boring manuals.

Today, instead of a dry lecture, we’re going to sneak backstage 🎭, peek into Python’s engine room 🛠️, and uncover the magic behind these concepts in a way that actually sticks in your brain.

By the end, you’ll see why Python feels like a language that bends reality and you’ll never look at a decorator the same way again.

Grab your popcorn 🍿 and let’s dive in.


🎯 Part 1: Scopes, The Treasure Map of Variables

Think of Python as a giant mansion 🏰. Each room (function/module) has drawers filled with valuables (variables).

When you ask for x, Python searches the drawers in this order:

  • Local (L) → Your room (the current function).
  • Enclosing (E) → Any outer function enclosing the current one.
  • Global (G) → The entire mansion (module-level namespace).
  • Built-in (B) → The outside world (Python’s built-in functions like len, print).

This is called the LEGB Rule.

👉 Example:

x = "Global"

def outer():
    x = "Enclosing"
    def inner():
        x = "Local"
        print(x)
    inner()

outer()
Enter fullscreen mode Exit fullscreen mode

Output:

Local
Enter fullscreen mode Exit fullscreen mode

Behind the scenes, Python builds a scope chain:

inner locals → outer locals → module globals → built-ins
Enter fullscreen mode Exit fullscreen mode

If it doesn’t find the name in any of those drawers? 💥 NameError.

So when you get a NameError, picture Python frantically running through the mansion yelling:

“I swear I checked every drawer… your socks are gone!” 🧦


🔄 Part 2: nonlocal, Variable Borrowing

Normally, assigning inside a function creates a new local drawer.

But sometimes you don’t want a new one, you want to borrow Dad’s drawer from the next room. That’s what nonlocal does.

👉 Example:

def outer():
    message = "Hello"
    def inner():
        nonlocal message
        message = "Hi from inner"
    inner()
    print(message)

outer()
Enter fullscreen mode Exit fullscreen mode

Output:

Hi from inner
Enter fullscreen mode Exit fullscreen mode

Without nonlocal, Python would assume you meant a brand-new variable in inner. With nonlocal, you’re saying:

“No Python, use the old drawer. I’m borrowing it.”


🪢 Part 3: Closures, Time Capsules for Functions

Closures are like time capsules or backpacks 🎒. Even after a function finishes, its inner function can carry some variables with it forever.

👉 Example:

def make_multiplier(n):
    def multiplier(x):
        return x * n
    return multiplier

times3 = make_multiplier(3)
times5 = make_multiplier(5)

print(times3(10))  # 30
print(times5(10))  # 50
Enter fullscreen mode Exit fullscreen mode

Even though make_multiplier has finished running, times3 and times5 still remember their n.

How? Closures!


🧬 The Secret: __closure__ and cell Objects

Closures don’t just magically “remember” variables, they literally carry them around in cells.

👉 Example:

def outer():
    x = 42
    def inner():
        return x
    return inner

func = outer()
print(func())  # 42
Enter fullscreen mode Exit fullscreen mode

Let’s peek behind the curtain:

print(func.__closure__)
print(func.__closure__[0].cell_contents)
Enter fullscreen mode Exit fullscreen mode

Output:

(<cell at 0x...: int object at 0x...>,)
42
Enter fullscreen mode Exit fullscreen mode

What does this mean?

  • __closure__ → A tuple of cell objects.
  • Each cell is like a glass jar 🫙 holding a variable.
  • cell_contents → The actual thing inside (here 42).

Closures are literally functions carrying jars of variables with them.


Multiple Cells Example

def outer():
    a = 10
    b = 20
    def inner():
        return a + b
    return inner

func = outer()

for i, cell in enumerate(func.__closure__):
    print(f"Cell {i}: {cell.cell_contents}")
Enter fullscreen mode Exit fullscreen mode

Output:

Cell 0: 10
Cell 1: 20
Enter fullscreen mode Exit fullscreen mode

So the closure is walking around with two jars 🫙🫙 one for a, one for b.


Closures Don’t Copy Values

Closures keep references, not snapshots.

def outer():
    x = [1, 2, 3]
    def inner():
        return x
    return inner

func = outer()
print(func.__closure__[0].cell_contents)  # [1, 2, 3]

func.__closure__[0].cell_contents.append(4)
print(func())  # [1, 2, 3, 4]
Enter fullscreen mode Exit fullscreen mode

See? The jar held the actual list, so when we changed it, the closure noticed.

This also explains why nonlocal works: it updates the existing jar instead of creating a new one.


🧰 Real-Life Closure Magic

  1. Counters that remember clicks
def make_counter():
    count = 0
    def counter():
        nonlocal count
        count += 1
        return count
    return counter

click = make_counter()
print(click())  # 1
print(click())  # 2
Enter fullscreen mode Exit fullscreen mode
  1. Function factories (specialized calculators).
  2. Data hiding (simulate private variables).

Closures = memory + functions.


🎭 Part 4: Decorators, Costume Designers for Functions

If functions are actors, decorators are the costume designers 🎭. They dress up functions with extra behavior, without changing their script.

👉 Basic Decorator:

def shout(func):
    def wrapper():
        return func().upper()
    return wrapper

@shout
def greet():
    return "hello world"

print(greet())  # HELLO WORLD
Enter fullscreen mode Exit fullscreen mode

Behind the scenes, @shout means:

greet = shout(greet)
Enter fullscreen mode Exit fullscreen mode

So greet is actually wrapper. No black magic, just reassignment.


⏱️ Practical Decorators in Action

  1. Timer
import time

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

@timer
def slow():
    time.sleep(2)
    return "Done"

slow()
Enter fullscreen mode Exit fullscreen mode
  1. Logger (record function calls).
  2. Memoization (cache expensive results).
  3. Security checks (only allow if logged in).
  4. Stacked decorators (chain multiple costumes).

🔮 Advanced Wizardry: Decorator Factories & Class Decorators

Decorators can also take arguments. That’s a decorator factory.

def repeat(times):
    def decorator(func):
        def wrapper(*args, **kwargs):
            for _ in range(times):
                func(*args, **kwargs)
        return wrapper
    return decorator

@repeat(3)
def say_hi():
    print("Hi!")

say_hi()
Enter fullscreen mode Exit fullscreen mode

Executed as:

say_hi = repeat(3)(say_hi)
Enter fullscreen mode Exit fullscreen mode

We can even decorate classes, transforming them when they’re created like handing Iron Man a brand-new suit 🦾.


🧠 The Big Picture

  • Scopes → Python’s treasure map 🗺️ for finding variables (LEGB rule).
  • Closures → Backpacks 🎒 carrying variables after their parents are gone.
  • __closure__ & cell → The jars 🫙 where closures actually store stuff.
  • Decorators → Costume designers 🎭 that rewire functions using closures.

The real superpower: functions in Python are first-class citizens. You can pass them, store them, return them, and wrap them. Everything else closures, decorators builds on that.


🎉 Final Curtain

Next time you see something like:

@something
def do_magic():
    ...
Enter fullscreen mode Exit fullscreen mode

Remember:

  • Python just swapped your function for a wrapped version.
  • That wrapper probably carries jars of variables in its closure.
  • And Python quietly checked the scope chain to make it all work.

That’s the real secret sauce of Python: 🪄 simple rules + flexible functions = endless magic.

Top comments (0)