All 57 lessons

Lesson 20 of 57 · Functions

Python Scope, Closures and Recursion

By Skillancy Editorial Team

Run the code in the free Python Compiler or on your own computer. Lessons that read files use the FreshBasket practice data: download the dataset (ZIP).

You now know how to write functions. This lesson answers three deeper questions that separate beginners from confident Python programmers. First: where does a variable live, and who can see it? That is scope. Second: can a function remember something between calls without using a global variable? That is a closure. Third: can a function call itself? That is recursion, the natural tool for anything shaped like a tree: folders inside folders, categories inside categories, or the nested JSON you get back from an API.

None of these ideas is exotic. Closures power decorators, which appear in every web framework. Recursion lets you walk a nested product catalogue in four lines. And scope rules explain a large share of the confusing NameError and UnboundLocalError messages you will meet. By the end you will be able to predict exactly what a snippet of code will do before you run it.

Worked examples

Example 1: The LEGB rule

Question: In what order does Python look for a name?

rate = 0.05                     # global

def outer():
    rate = 0.12                 # enclosing (local to outer)
    def inner():
        return rate             # no local rate, so look outward
    return inner()

print(outer())
print(rate)
print(len)                      # built-in, found last

Output

0.12
0.05
<built-in function len>

How it works: When Python meets a name it searches four places in order: Local (the current function), Enclosing (any functions around it), Global (the module) and Built-in (names like len). inner has no rate of its own, so it finds the one in outer before it ever reaches the global one.

Reading the result: outer() returned 0.12 because the enclosing variable shadowed the global 0.05. A name defined closer to where it is used always wins.

Example 2: Assignment makes a variable local

Question: Why does reading a global work, but changing it fail?

orders_today = 0

def read_only():
    return orders_today + 1

def try_to_change():
    orders_today = orders_today + 1
    return orders_today

print(read_only())
try:
    try_to_change()
except UnboundLocalError as e:
    print("UnboundLocalError:", e)

Output

1
UnboundLocalError: cannot access local variable 'orders_today' where it is not associated with a value

How it works: Python decides whether a name is local by looking at the whole function body before running it. If the function assigns to the name anywhere, the name is local everywhere inside that function. In try_to_change, the right-hand side reads a local variable that has not been given a value yet.

Reading the result: The read-only function worked and returned 1. The changing function failed with an UnboundLocalError, even though a global of that name exists.

Example 3: global and nonlocal

Question: How do you deliberately change an outside variable?

count = 0

def bump():
    global count
    count += 1

bump(); bump()
print(count)

def make_counter():
    n = 0
    def tick():
        nonlocal n
        n += 1
        return n
    return tick

counter = make_counter()
print(counter(), counter(), counter())

Output

2
1 2 3

How it works: global count tells Python the name refers to the module-level variable, so assignment changes it. nonlocal n does the same for a variable in an enclosing function. Both work, but shared mutable state makes programs hard to test. Prefer parameters and return values, and use these keywords sparingly.

Reading the result: After two bumps, count is 2. The counter returned 1, 2, 3 on successive calls because tick remembers its own n.

Example 4: Closures

Question: Create several customised functions from one template.

def make_tax_calculator(rate):
    def calc(price):
        return round(price * (1 + rate), 2)
    return calc

gst5 = make_tax_calculator(0.05)
gst18 = make_tax_calculator(0.18)

print(gst5(200), gst18(200))
print(gst5.__closure__[0].cell_contents)

Output

210.0 236.0
0.05

How it works: calc uses the variable rate from its enclosing function. After make_tax_calculator returns, Python keeps rate alive inside the returned function; this bundle is called a closure. Each call to the factory creates an independent closure with its own rate.

Reading the result: The same ₹200 becomes 210.0 under 5% GST and 236.0 under 18%. The remembered rate for gst5 is 0.05. Decorators, which you will meet later, are built on exactly this pattern.

Example 5: The late-binding gotcha

Question: Why do functions created in a loop all use the last value?

funcs = [lambda: i for i in range(3)]
print([f() for f in funcs])

funcs_fixed = [lambda i=i: i for i in range(3)]
print([f() for f in funcs_fixed])

Output

[2, 2, 2]
[0, 1, 2]

How it works: A closure looks up the variable when the function is called, not when it is created. By then the loop has finished and i is 2 for all three lambdas. Binding the current value as a default argument (i=i) captures it at creation time.

Reading the result: The naive version gave [2, 2, 2] and the fixed one gave [0, 1, 2]. Remember this when you create functions or buttons inside loops.

Example 6: Recursion basics

Question: Compute a factorial and a countdown recursively.

def factorial(n):
    if n <= 1:                 # base case
        return 1
    return n * factorial(n - 1)   # recursive case

print(factorial(5))
print(factorial(10))

def countdown(n):
    if n == 0:
        print("liftoff")
        return
    print(n)
    countdown(n - 1)

countdown(3)

Output

120
3628800
3
2
1
liftoff

How it works: Every recursive function needs a base case that stops the chain and a recursive case that moves closer to it. factorial(5) waits for factorial(4), which waits for factorial(3), and so on down to 1, then the answers multiply on the way back up. Each waiting call sits on the call stack.

Reading the result: 5! is 120 and 10! is 3,628,800. If you forget the base case, the calls never stop and Python raises a RecursionError.

Example 7: Walking nested data

Question: Count every product in a category tree of any depth.

catalogue = {
    "name": "All",
    "children": [
        {"name": "Dairy", "children": [
            {"name": "Milk", "children": []},
            {"name": "Cheese", "children": [
                {"name": "Paneer", "children": []},
                {"name": "Cheddar", "children": []},
            ]},
        ]},
        {"name": "Fruits", "children": [
            {"name": "Banana", "children": []},
        ]},
    ],
}

def leaves(node):
    if not node["children"]:
        return [node["name"]]
    names = []
    for child in node["children"]:
        names.extend(leaves(child))
    return names

def depth(node):
    if not node["children"]:
        return 1
    return 1 + max(depth(c) for c in node["children"])

print(leaves(catalogue))
print(depth(catalogue))

Output

['Milk', 'Paneer', 'Cheddar', 'Banana']
4

How it works: A node with no children is a leaf, which is the base case. Otherwise we ask each child the same question and combine the answers. You do not need to know how deep the tree goes; the function handles it naturally. This is the standard way to process nested JSON, folder trees and organisation charts.

Reading the result: The tree has 4 leaf products, ['Milk', 'Paneer', 'Cheddar', 'Banana'], and a depth of 4 levels.

Example 8: Flattening nested lists

Question: Flatten a list whose items may themselves be lists.

def flatten(items):
    flat = []
    for item in items:
        if isinstance(item, list):
            flat.extend(flatten(item))
        else:
            flat.append(item)
    return flat

messy = [1, [2, [3, 4]], 5, [[6], 7]]
print(flatten(messy))

Output

[1, 2, 3, 4, 5, 6, 7]

How it works: For each element we test whether it is a list; if so we recurse and merge the flattened result, otherwise we keep the value. The list comprehension from earlier flattens only one level; recursion handles any depth.

Reading the result: The nested structure collapsed into 7 values: [1, 2, 3, 4, 5, 6, 7].

Example 9: Memoization and the recursion limit

Question: Why is naive Fibonacci slow, and how do you fix it?

import sys
from functools import lru_cache
import time

def fib_slow(n):
    return n if n < 2 else fib_slow(n - 1) + fib_slow(n - 2)

@lru_cache(maxsize=None)
def fib_fast(n):
    return n if n < 2 else fib_fast(n - 1) + fib_fast(n - 2)

t0 = time.perf_counter(); a = fib_slow(25); slow = time.perf_counter() - t0
t0 = time.perf_counter(); b = fib_fast(25); fast = time.perf_counter() - t0

print(a == b, slow > fast)
print(fib_fast(90))
print(sys.getrecursionlimit())
print(fib_fast.cache_info().currsize > 0)

Output

True True
2880067194370816120
1000
True

How it works: The slow version recomputes the same values many times: fib_slow(25) makes hundreds of thousands of calls. @lru_cache is a decorator that stores each answer so it is computed once. Python also limits recursion depth (about 1000 by default) to protect memory; for deep problems, convert the recursion to a loop.

Reading the result: Both functions agree on 75025, and the cached one was faster (True). The cache even computes fib(90) = 2,880,067,194,370,816,120 instantly, and the recursion limit here is 1000.

Example 10: Recursion versus a loop

Question: When is a loop the better choice?

def sum_recursive(nums):
    if not nums:
        return 0
    return nums[0] + sum_recursive(nums[1:])

def sum_loop(nums):
    total = 0
    for n in nums:
        total += n
    return total

data = list(range(1, 101))
print(sum_recursive(data), sum_loop(data), sum(data))

try:
    sum_recursive(list(range(5000)))
except RecursionError as e:
    print("RecursionError")

Output

5050 5050 5050
RecursionError

How it works: All three give the same answer for 100 numbers, but the recursive version copies the list at every step and fails for 5000 items. Use recursion when the data is naturally nested (trees); use loops or built-ins for flat sequences.

Reading the result: The sum of 1 to 100 is 5,050 by every method, but only the loop and the built-in scale to long lists.

Common mistakes

1. Missing base case.

def forever(n):
    return forever(n + 1)
forever(1)

Output

Traceback (most recent call last):
  File "example.py", line 3, in <module>
    forever(1)
    ~~~~~~~^^^
  File "example.py", line 2, in forever
    return forever(n + 1)
  File "example.py", line 2, in forever
    return forever(n + 1)
  File "example.py", line 2, in forever
    return forever(n + 1)
  [Previous line repeated 993 more times]
RecursionError: maximum recursion depth exceeded

2. Modifying a global without declaring it (UnboundLocalError).

3. Late binding in loops. Use default arguments to capture the current value.

4. Using recursion on flat data, which is slower and risks the limit.

5. Mutable shared state in closures that makes results depend on call history; document it or avoid it.

Practice questions

Practice 1 (Easy): Recursive sum of digits

Write digit_sum(n) recursively and test it on 9875.

Show solution
def digit_sum(n):
    if n < 10:
        return n
    return n % 10 + digit_sum(n // 10)

print(digit_sum(9875))

Output

29

The base case is a single digit; otherwise we add the last digit to the sum of the rest. The answer is 29.

Practice 2 (Medium): A call counter

Write make_logger(prefix) that returns a function; each call to it prints prefix #n: message, with n increasing.

Show solution
def make_logger(prefix):
    n = 0
    def log(message):
        nonlocal n
        n += 1
        print(f"{prefix} #{n}: {message}")
    return log

info = make_logger("INFO")
info("started")
info("loaded data")

Output

INFO #1: started
INFO #2: loaded data

The closure keeps prefix and the counter n private to each logger. Two loggers would count independently.

Practice 3 (Hard): Total of a nested dictionary

Write deep_sum(d) that adds all the numbers inside a dictionary whose values may themselves be dictionaries. Test it on the store data below.

Show solution
def deep_sum(d):
    total = 0
    for value in d.values():
        if isinstance(value, dict):
            total += deep_sum(value)
        else:
            total += value
    return total

sales = {"Pune": {"UPI": 740, "Card": 250}, "Delhi": {"UPI": 520, "Card": {"Visa": 100, "Rupay": 90}}}
print(deep_sum(sales))

Output

1700

The base case is a plain number. The answer is 1,700, and the function would work for any nesting depth.

Key takeaways

  • Python looks up names in the order Local, Enclosing, Global, Built-in.
  • Assigning to a name inside a function makes it local unless you declare global or nonlocal.
  • A closure is a function that remembers variables from where it was created.
  • Recursion needs a base case and a smaller subproblem on every call.
  • Recursion fits nested data; loops fit flat data.
  • @lru_cache makes repeated recursive calculations fast.

Frequently asked questions

What is the difference between a closure and a class?

Both can hold state. A closure is lighter for one behaviour with a little state; a class is better when you need several methods or many attributes.

Is recursion slower than loops in Python?

Usually yes, because each call has overhead and Python does not optimise tail calls. Use it when it makes nested problems clearer.

How do I avoid a RecursionError?

Make sure every path reaches the base case, shrink the problem each call, and switch to a loop or an explicit stack for very deep inputs.

Should I ever use global?

Rarely: for constants and configuration set once. For changing state, pass values in and return new ones.

What does @lru_cache do?

It remembers the results for each distinct set of arguments, so repeated calls with the same arguments return instantly. Arguments must be hashable.

Why do enclosing variables appear in __closure__?

That is where Python stores the "remembered" values. You rarely need to touch it; it is shown only to demonstrate that the closure really holds them.

Newsletter

Keep learning Python

One email a week with Python and analytics guides, new practice questions and cohort dates. No spam, unsubscribe anytime.

Ready to practise? Try the Python interview questions