AI EngineeringZero to ProductionHome·About·What’s new·Contact
Appendix · Python for AI Agents · Part 9

Functional & recursion theory

Functions in Python are first-class values, which makes a whole functional toolkit available: closures that capture state, pure functions you can cache and parallelize safely, and higher-order functions that take or return other functions. This part makes the theory precise. You will see how a closure stores free variables in cells (and the famous late-binding trap that follows), why immutability makes reasoning and memoization sound, how recursion really uses the call stack — including that CPython does no tail-call optimization, so deep recursion raises RecursionError — and how to convert any recursion into an explicit stack. We close with map/filter/reduce vs comprehensions and partial application, each with complexity notes.

⏱️ ~2.5 hours🎯 Advanced → Industryλ functional + recursionrunnable CPython 3.13

Learning objectives

  • Explain closures in terms of free variables and cells, and avoid the late-binding trap.
  • Define pure functions and immutability, and see why the mutable-default-argument bug happens.
  • Use higher-order functions: passing functions in and returning them out.
  • Reason about recursion via the call stack, the recursion limit, and CPython's lack of tail-call optimization.
  • Convert a recursive algorithm to an explicit-stack iterative one to remove the depth limit.
  • Apply memoization, choose between map/filter/reduce and comprehensions, and use partial/currying — with complexity in mind.

1 · Closures — free variables & cells

Here's a moment you've probably had without a name for it. You needed a little function that "remembers" something — a running total, a rate limiter that knows how many calls are left, a callback that needs one config value — and your instinct said "I'll write a class for this." A whole class, with __init__ and self and a method, just to hold one number between calls. It works, but it feels like renting a truck to carry a sandwich. A closure is the sandwich-sized answer: a function that quietly carries a little private memory with it, no class required.

The everyday analogy is a backpack. When you define a function inside another function, the inner function walks away with a backpack containing the outer function's variables it actually used. It can reach into that backpack later — long after the outer function has returned and its floor of the building has been demolished — because the variables it needs were packed away, not left behind on the stack. That "the variable outlives the function that created it" is the whole trick, and it's why counters, accumulators, and pre-configured callbacks can be built out of nothing but functions.

Why this matters beyond cuteness: closures are the machinery under decorators, under functools.partial, under most callback-based APIs, and under the late-binding bug in the very next section. If closures are fuzzy, all of those stay fuzzy too. And the common mistake is assuming the closure copied the value — it did not. It captured the variable itself (a shared box CPython calls a cell), so if that variable changes later, the closure sees the new value. Hold onto that distinction; it is the single most misunderstood thing about closures and it causes real production bugs.

A closure is a nested function together with the enclosing variables it references — its free variables. CPython stores each captured variable in a cell shared between the outer and inner functions, so the inner function keeps that state alive after the outer returns. Each call to the factory creates fresh cells, so closures have independent state. nonlocal lets the inner function rebind the captured name rather than shadow it with a new local.

while make_counter() runs make_counter() frame inc (fn) cell count inc holds a reference to the cell, not a copy after make_counter() returns inc (kept) cell count frame gone — cell lives on c1() → 1 → 2 → 3 (own cell) c2() → 1 (fresh cell) each factory call makes a new cell The variable outlives its function. inc keeps a reference to the shared cell holding count, so the state survives after make_counter's frame is gone — and each factory call creates an independent cell.
🗺️ How to read this diagram

Read left-to-right: the left panel is the moment the factory runs; the right panel is after it has returned.

  • The left box is make_counter()'s frame. Inside it, the inc function (amber) and the cell holding count (the purple box) are created together; the blue arrow is inc's reference to that cell — it points at the box, it does not copy the value.
  • The dashed divider marks the return. On the right the frame is demolished, yet the green inc still points at the same live cell — that is the closure "carrying its backpack."
  • The green lines are the payoff: c1 counts 1→2→3 from its own cell while c2 starts fresh at 1, because each call to the factory built a brand-new cell.

In short: A closure captures the cell (the variable), not a snapshot of its value — which is exactly why counters persist and why the §2 bug happens.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Try it
closures.py# A closure is a function plus the enclosing variables it captures ("free vars").
def make_counter():
    count = 0                       # a free variable for the inner function
    def inc():
        nonlocal count              # rebind the enclosing name, don't shadow it
        count += 1
        return count
    return inc

c1, c2 = make_counter(), make_counter()
print(c1(), c1(), c1())             # 1 2 3   -> each closure has its OWN cell
print(c2())                         # 1       -> independent state

# Introspection: free variables live in code.co_freevars and in __closure__ cells
print("free vars:", make_counter().__code__.co_freevars)   # ('count',)
print("has closure cells:", make_counter().__closure__ is not None)
1 2 3
1
free vars: ('count',)
has closure cells: True

2 · The late-binding gotcha

This is the bug that makes you doubt your own sanity. You write a clean little loop that builds three callbacks — one per item — and every single one, when you finally run it, does the same thing: it uses the last item. Not the item it was "supposed" to. You stare at it. You add print statements. The loop is obviously iterating over 0, 1, 2 — you can see it — yet all three functions insist the answer is 2. Nearly every Python engineer meets this at least once, usually in event handlers, retry callbacks, or a list of partially-applied jobs, and usually in production rather than in a tutorial where it would have been harmless.

Here is the plain-language reason, and it flows directly from the backpack idea in §1. The three closures did not each pack their own copy of i. They all packed a reference to the same box — the one variable i that the loop keeps overwriting. Think of three people who each wrote down "check the whiteboard for the number" instead of copying the number down. The loop kept erasing and rewriting the whiteboard, and by the time anyone actually looks, the whiteboard says 2. The closures were never wrong; they faithfully read the shared box at call time, and by call time the loop had long since finished and left its final value there.

The mistake to name: "the loop variable is captured by value." It is not — it is captured by reference, and the read happens late (hence late binding). Once you internalize that, the fix is obvious rather than magical: force each closure to write the number down now, at definition time. The two idioms below both do exactly that — a default argument (defaults are evaluated once, at def time, and stored on the function) or a factory function that closes over its own fresh parameter. Both give each closure its own private box instead of a shared whiteboard.

Closures capture the variable, not the value it had when the closure was created. So building lambdas in a loop that all reference the loop variable makes them all see its final value — a bug that bites everyone once. The idiomatic fix is to bind the current value as a default argument (defaults are evaluated at definition time), or to close over a parameter via a factory function.

lambda: i (shared variable) f0 f1 f2 cell i = 2 [2, 2, 2] all read the one final cell lambda i=i (own capture) f0 f1 f2 i=0 i=1 i=2 [0, 1, 2] each captured at def time One shared box vs three private boxes. On the left all three lambdas point at the same cell, which ends at 2 → [2,2,2]. The i=i default evaluates once at def time, giving each closure its own value → [0,1,2].
🗺️ How to read this diagram

Compare the two panels side by side: same three lambdas, two very different capture stories.

  • The left (red) panel is the bug. All three lambdas f0/f1/f2 point their red arrows at a single cell for i. The loop leaves that cell at 2, so every call reads 2 → [2, 2, 2].
  • The right (green) panel is the fix, lambda i=i. Each lambda now points at its own amber box, because a default argument is evaluated once at def time and stored on that function — 0, 1, 2 frozen in place.
  • The payoff is the two result lines: the same loop yields [2,2,2] when the box is shared and [0,1,2] when each closure records its value now.

In short: Late binding = one shared box read too late; the default-argument fix gives each closure a private box written at definition time.

Try it
late_binding.py# The late-binding gotcha: closures capture the VARIABLE, not its value-at-creation.
funcs = [lambda: i for i in range(3)]
print("late binding:", [f() for f in funcs])   # [2, 2, 2]  (all see final i)

# Fix 1: bind the current value as a default argument (evaluated at def time)
fixed = [lambda i=i: i for i in range(3)]
print("default-arg fix:", [f() for f in fixed])# [0, 1, 2]

# Fix 2: a factory that closes over its own parameter
def make(i):
    return lambda: i
fixed2 = [make(i) for i in range(3)]
print("factory fix:", [f() for f in fixed2])   # [0, 1, 2]
late binding: [2, 2, 2]
default-arg fix: [0, 1, 2]
factory fix: [0, 1, 2]
This is not a Python quirk to memorize — it follows from the modelBecause a closure holds a reference to the enclosing variable (a cell), and the loop keeps updating that one variable, every lambda reads the same final cell. The default-argument fix works precisely because default values are computed once, at def time, and stored on the function.

3 · Pure functions & immutability

Think about a vending machine versus a slot machine. Press B4 on a vending machine and you get the same granola bar every time — same input, same output, and nothing about the machine's mood or the time of day changes that. A slot machine takes the same coin and does something different each pull, and it quietly changes the world (your balance, the jackpot) as a side effect. A pure function is the vending machine: given the same arguments it always returns the same answer, and it touches nothing outside itself. That reliability sounds like a nice-to-have until the day an impure function ruins your afternoon.

And it will. The reason purity earns a whole section is that impurity is where the spooky bugs live — the ones that pass every unit test and then misbehave only in production, only sometimes, only under load. A function that reads a global, or mutates the list you handed it, or depends on the current time, works perfectly in isolation and then does something different when a second caller, a second thread, or a cache gets involved. Pure functions have none of that: you can call them in any order, from any thread, cache their results, and reason about them by just reading the code. Purity is what makes the memoization in §7 correct, makes testing a one-liner (no setup, no mocks), and makes the parallel data pipeline in the ladder safe.

The classic mistake — and it is a rite of passage — is the mutable default argument. You write def f(item, bucket=[]) expecting a fresh empty list every call. But that [] is evaluated once, when the def line runs, and the same list object is reused on every future call — so it silently accumulates state, like a "clean" plate that's actually the same plate handed to every diner. It is the impurity of §1's shared-cell idea wearing a different hat: one object, secretly shared, mutated behind your back. The cure is the None-sentinel habit shown below, and once it's muscle memory you'll never write the bug again.

A pure function returns the same output for the same inputs and has no side effects — it does not mutate arguments, globals, or the outside world. Purity is what makes memoization correct, testing trivial, and parallelism safe. Its opposite shows up in the notorious mutable default argument: a default list is created once and shared across calls, so it accumulates state across invocations.

pure — same in, same out args pure fn output cacheable · testable · parallel-safe impure — reads/mutates outside args impure fn shared default/ global state leaks across calls Purity draws a box around the function. The pure side only maps args→output and touches nothing else, so results are cacheable and safe to run in parallel. The impure side reaches out to shared/global state — the door through which the mutable-default bug walks in.
🗺️ How to read this diagram

Read each side left-to-right as one function call, and watch whether any arrow leaves the function.

  • The left (green) side is a pure function: args flow in, an output comes out, and no arrow escapes to anything else. That self-containment is why the green line lists it as cacheable, testable, and parallel-safe.
  • The right (red) side is impure: besides its args it has red arrows both into and out of a shared default / global box. Reading that box makes results unpredictable; writing it (the two-way arrow) is exactly the mutable-default bucket=[] bug.
  • The payoff is the contrast in the bottom labels: "same in, same out" versus "state leaks across calls" — the difference the memoization in §7 depends on.

In short: If any arrow leaves the function to touch shared state, it is impure — and caching, testing, and parallelism all get harder.

Try it
pure.py# A PURE function: output depends only on inputs, and it mutates nothing external.
def pure_add_tax(price, rate):
    return round(price * (1 + rate), 2)      # no side effects, deterministic

# An IMPURE function: mutates its argument (a side effect) -> surprises callers.
def impure_append(item, bucket=[]):          # classic mutable-default bug!
    bucket.append(item)
    return bucket

print(pure_add_tax(100, 0.2))                # 120.0  (same input -> same output)
print(pure_add_tax(100, 0.2))                # 120.0
print(impure_append(1))                      # [1]
print(impure_append(2))                      # [1, 2]  <- shared default persists!

# The fix for the mutable default:
def safe_append(item, bucket=None):
    bucket = [] if bucket is None else bucket
    bucket.append(item)
    return bucket
print(safe_append(1), safe_append(2))        # [1] [2]
120.0
120.0
[1]
[1, 2]
[1] [2]
The None sentinel patternNever use a mutable object ([], {}) as a default argument. Use None and build the fresh object inside the function. This single habit prevents a whole class of shared-state bugs.

4 · Higher-order functions

You already trust higher-order functions in real life — you just don't call them that. When you tell a contractor "for each room, do whatever this checklist says," you're handing over a set of instructions (a function) to be applied by something else. When you give a recipe card to a friend and say "run this on those vegetables," you're passing behavior, not just data. In Python, functions are ordinary first-class values — you can put one in a variable, drop it in a list, pass it into another function, or hand one back as a result — and a higher-order function is simply one that takes or returns a function. That's the entire concept; the rest is vocabulary.

Why does treating functions as data matter enough to teach on its own? Because it lets you separate the what from the how. map knows how to walk a sequence; you supply the "what to do to each item." filter knows how to keep-or-drop; you supply the test. reduce knows how to fold a whole sequence down to one value; you supply the combining step. This is exactly how you plug your own scoring function into a re-ranker, your own validator into a pipeline, or your own tool into an agent loop — the framework provides the machinery, you provide the behavior. And returning a function (a closure, from §1) is how decorators, configurable adders, and pre-wired callbacks get built.

Two mistakes to sidestep. First, map and filter return lazy iterators, not lists — nothing actually runs until you consume them, so a bare map(...) that you never iterate does nothing, and printing it shows a <map object> rather than results (wrap it in list() to force it). Second, don't reach for map+filter+lambda when a comprehension says the same thing more clearly: for a simple transform-and-filter, [x*x for x in nums if x%2] reads better and runs at least as fast. Save map/filter for when you already have a named function to pass, or when you genuinely want the lazy iterator to stream a large source without materializing it.

Functions are first-class values: you can store them, pass them as arguments, and return them. A higher-order function does one of the last two. map and filter take a function and an iterable and return lazy iterators; reduce folds a sequence to one value. Returning a function is how decorators, adders, and configurable callbacks are built.

map: fn per item x → x*x filter: keep if test keep evens reduce: fold to one sum a,b → a+b you supply the fn (blue arrow); the tool supplies the machinery Three shapes of "pass behavior in". map keeps the count but transforms each item; filter keeps a subset unchanged; reduce collapses the whole sequence to one value. In every case you plug in the function.
🗺️ How to read this diagram

Read each panel top-to-bottom: the top row is the input sequence, the bottom is the result, and the blue arrow is the function you hand in.

  • The map panel keeps four items but each blue box becomes a green one — same length, transformed values (x → x*x).
  • The filter panel passes the same items through a test and keeps only the two that pass — fewer boxes, values unchanged.
  • The reduce panel funnels every item into a single purple box via a combining step (a,b → a+b) — the whole sequence folded to one value.

In short: map transforms each, filter keeps some, reduce combines all — and the blue arrow is the behavior you supply, which is what makes them higher-order.

Try it
hof.pyfrom functools import reduce

nums = [1, 2, 3, 4, 5]

# map / filter are lazy iterators; wrap in list() to materialize.
print("map    :", list(map(lambda x: x * x, nums)))        # [1, 4, 9, 16, 25]
print("filter :", list(filter(lambda x: x % 2 == 0, nums)))# [2, 4]
print("reduce :", reduce(lambda a, b: a + b, nums, 0))     # 15

# The Pythonic equivalents are comprehensions (usually clearer & as fast):
print("comp   :", [x * x for x in nums if x % 2])          # [1, 9, 25]

# Higher-order: a function that RETURNS a function (a decorator-style adder).
def adder(n):
    def add(x): return x + n
    return add
add10 = adder(10)
print("hof    :", add10(5))                                # 15
map    : [1, 4, 9, 16, 25]
filter : [2, 4]
reduce : 15
comp   : [1, 9, 25]
hof    : 15
map/filter vs comprehensionsFor a simple transform-and-filter, a comprehension [x*x for x in nums if x%2] is usually clearer than map+filter+lambda and is at least as fast. Reach for map/filter when you already have a named function, or want the lazy iterator for streaming.

5 · Recursion, the call stack & no TCO

Picture standing between two mirrors. You see yourself, holding a reflection of yourself, holding a reflection of yourself, on and on — each image nested inside the last. Recursion is a function that solves a problem by calling itself on a smaller version of the same problem, and those nested calls are exactly like those nested reflections. It's the most natural way to describe anything tree-shaped: a folder full of folders, a comment thread of replies to replies, a nested JSON blob. You say "process this node, then process each child the same way," and the structure does the bookkeeping for you.

But there's a physical cost the mirror analogy hides, and it's the point of this section. Every time one function calls another, Python sets aside a little workspace called a frame — a note of the arguments, the local variables, and where to return when the call finishes. Those frames pile up on the call stack, one per pending call, and they aren't cleaned up until each call returns. So a recursion that's 50,000 levels deep needs 50,000 frames sitting in memory simultaneously, like 50,000 half-finished tasks on your desk, none of which you can file until the innermost one completes. To stop a runaway recursion from crashing the whole interpreter, Python caps this at a default depth of 1000 and raises a clean RecursionError instead.

Now the mistake that catches experienced engineers — especially anyone coming from Lisp, Scheme, or a functional language. There, a "tail call" (when the recursive call is the very last thing the function does) is optimized away: the interpreter reuses the current frame instead of stacking a new one, so tail recursion runs in constant stack space forever. It is tempting to assume Python does the same. It does not. CPython performs no tail-call optimization — a tail call still allocates a fresh frame just like any other call, so even a perfectly tail-recursive countdown blows the stack at depth. This was a deliberate design decision (frames are what make tracebacks readable), not an oversight. The takeaway: in Python, don't lean on recursion for anything that can go deep — reach for a loop, or the explicit stack you'll build in §6.

Each function call pushes a frame onto the call stack (arguments, locals, return address). Recursion just calls the same function again, stacking frames until a base case unwinds them. Python caps recursion depth (default 1000) to catch runaway recursion before it exhausts the C stack. Crucially, CPython performs no tail-call optimization: even a function whose recursive call is the last thing it does still allocates a new frame, so deep recursion raises RecursionError.

Try it
recursion.pyimport sys

# Recursion uses the call stack: each call adds a frame. Python does NOT optimize
# tail calls, so recursion depth is bounded by the recursion limit.
print("recursion limit:", sys.getrecursionlimit())   # 1000 by default

def factorial(n):
    return 1 if n == 0 else n * factorial(n - 1)      # not tail-optimized either
print("factorial(10):", factorial(10))                # 3628800

# Even a perfectly tail-recursive function blows the stack — no TCO in CPython:
def count_down(n):
    if n == 0:
        return "done"
    return count_down(n - 1)                          # tail call, still a new frame

try:
    count_down(100_000)
except RecursionError:
    print("RecursionError: CPython has no tail-call optimization")
recursion limit: 1000
factorial(10): 3628800
RecursionError: CPython has no tail-call optimization
count_down(3) frame 1 count_down(2) frame 2 count_down(1) frame 3 count_down(0) base case unwind return up
🗺️ How to read this diagram

Follow the arrows left-to-right: each recursive call pushes a new frame, and only the last box turns the flow back around.

  • The three blue boxes — count_down(3), count_down(2), count_down(1), labelled frame 1, 2, 3 — are the stack frames stacking up, one per call, none of them reused.
  • The arrows point strictly rightward as the calls go deeper, until the green box count_down(0) marks the base case that finally stops the descent.
  • The last arrow reaches the amber box labelled unwind / return up — the point where the frames finally return back up the chain.

In short: without TCO, every recursive call keeps its own frame around until the base case unwinds — so the depth is what eats the stack.

No TCO is a deliberate CPython choiceGuido van Rossum has explained the omission as intentional: tail-call elimination would erase frames from tracebacks, hurting debuggability. So in Python, prefer iteration or an explicit stack for deep problems; don't rely on the interpreter to flatten tail recursion the way Scheme does.

6 · Recursion → explicit stack

Here's the fix for the crash you just met in §5, and it's more clever than it first sounds. When recursion overflows, the reflex is to bump sys.setrecursionlimit higher — and that's a trap, because the real limit is the fixed-size C stack underneath, and pushing past it segfaults the whole process instead of raising a catchable error. The professional move is different: stop letting Python manage the stack for you, and manage one yourself. It turns out the call stack was never magic — it was just a stack — so you can replace it with an ordinary list that you push and pop by hand.

The analogy that makes this click: recursion is like reading a book and, every time you hit a footnote, immediately flipping to it — and if that footnote has its own footnote, flipping again, keeping every page held open with a finger. Run out of fingers and you're stuck. The explicit-stack version is keeping a to-read list on a notepad instead: jot down each footnote you encounter, finish your current line, then pop the next item off the notepad when you're ready. Same pages get read, same order if you want it — but now your "how deep can I go" limit is how much paper you have (memory, on the heap), not how many fingers (frames, on the tiny call stack).

That shift buys you three things worth naming. First, no RecursionError — a 50,000-deep structure is no harder than a 5-deep one, because the notepad grows on the heap. Second, control: because you own the loop, you can pause and resume, cap the work, or swap traversal order just by changing how you push and pop. Third, honesty about cost — the memory that recursion was quietly spending on frames is now visible as the size of your list. The one subtlety to watch (the common mistake): a stack reverses order, so if left-to-right traversal matters, push children in reverse so they pop back in the order you meant — exactly the reversed() detail you'll see in the DFS exercise later.

Any recursion can be rewritten with an explicit stack (a list) that you push and pop yourself. The work then lives on the heap instead of the call stack, so it is bounded only by memory — no RecursionError, and you can pause/resume or change traversal order at will. This is the standard fix when a tree or nested structure can be arbitrarily deep.

recursion → the call stack frame frame frame limit 1000 ✗ RecursionError fixed-size C stack explicit list → the heap stack.append(child) node = stack.pop() …grows on demand… 50k deep ✓ bounded by memory, not depth Move the stack off the call stack. Recursion piles frames on the tiny fixed C stack and dies at ~1000; a plain list you push/pop lives on the heap and scales to 50k-deep nesting — same work, no RecursionError.
🗺️ How to read this diagram

Compare the two piles: both are stacks, but they live in different places with different limits.

  • The left pile is the call stack — one frame per pending recursive call. It sits on a small fixed-size C stack, so once it crosses the ~1000 limit the top frame turns red and Python raises RecursionError.
  • The right pile is your own list: you append children and pop the next node yourself. It lives on the heap, which grows on demand, so a 50,000-deep structure is fine (the amber "50k deep ✓").
  • The payoff is the bottom label: the explicit stack is bounded by memory, not by a hard depth cap — and you also gain control over traversal order and pause/resume.

In short: Same push/pop logic, but a heap list has no fixed depth limit — so arbitrarily deep trees stop crashing.

Try it
explicit_stack.py# Convert recursion to an EXPLICIT stack -> no depth limit, iterative control.
def sum_nested_recursive(x):
    if isinstance(x, int):
        return x
    return sum(sum_nested_recursive(c) for c in x)

def sum_nested_iterative(root):
    total, stack = 0, [root]
    while stack:                    # our own stack on the heap, not the call stack
        node = stack.pop()
        if isinstance(node, int):
            total += node
        else:
            stack.extend(node)      # push children
    return total

data = [1, [2, 3, [4, 5]], 6]
print("recursive:", sum_nested_recursive(data))   # 21
print("iterative:", sum_nested_iterative(data))   # 21

# The iterative version handles arbitrarily deep nesting without RecursionError.
deep = 1
for _ in range(50_000):
    deep = [deep]                    # 50k-deep nesting
print("deep sum:", sum_nested_iterative(deep))     # 1
recursive: 21
iterative: 21
deep sum: 1

7 · Memoization & complexity

Watch a child compute Fibonacci by hand and you'll see the waste instantly. To get fib(5) they compute fib(4) and fib(3); but fib(4) re-computes fib(3) all over again, and fib(3) re-computes fib(2), which has already been worked out on another branch — the same subproblems solved again and again, an entire branching tree of duplicated effort. It's like a student who, every time they need a fact, re-derives it from scratch instead of jotting it in the margin. The tree of calls grows exponentially; asking for fib(50) naively will out-wait your patience.

Memoization is simply "write the answer in the margin." The first time you compute fib(n), you stash the result in a lookup table keyed by n; every later request for that same n is a table read instead of a recomputation. That one habit collapses the exponential call tree into a straight line — each value is computed exactly once, so an O(φn) disaster becomes O(n) time, at the cost of O(n) space to hold the table. This is the seed of dynamic programming, and it's the same trick prompt-caching and RAG result-caching use at a larger scale: pay once, remember, reuse. Python hands it to you as a single decorator, functools.lru_cache, so you rarely write the table by hand.

But the decorator is a loaded gun if you forget §3. Memoization is only correct for pure functions with hashable arguments — because a cache is a bet that "same inputs mean same output," and that bet only pays off if the function has no hidden dependence on time, randomness, global state, or mutable inputs. The classic mistakes: caching a function that reads a database or the clock (you serve stale answers forever), passing a mutable argument like a list (it's unhashable and raises TypeError, or worse, the key stops reflecting the value after mutation), or caching a per-user result and leaking one user's data to another. And leaving maxsize=None on a hot path is a slow memory leak. Cache the pure core, pass tuples/frozensets, bound the size — then reap the speedup safely.

Memoization caches a pure function's results so repeated inputs are returned instantly. On naive recursive Fibonacci it collapses an exponential O(φn) call tree into linear O(n) time (each n computed once) at O(n) space. functools.lru_cache does this in one decorator — but only correctly for pure functions with hashable arguments.

naive fib(5) — O(φⁿ) fib5 fib4 fib3 fib3 fib2 red = recomputed branches explode: same fib3, fib2 again memoized fib — O(n) fib5 fib4 fib3 fib2 cache table 5:5 4:3 3:2 2:1 each n computed once later reads hit the table A branching tree collapses to a line. Naive recursion re-solves fib3, fib2… on every branch (the red repeats) — exponential work. Memoization records each answer once in a table, so the tree becomes a single O(n) chain of first-time computes.
🗺️ How to read this diagram

Read both sides top-down from fib5; on the left every node spawns children, on the right each value appears once.

  • The left tree is naive recursion: fib5 calls fib4 and fib3, which call their own children, and the red boxes mark subproblems (fib3, fib2) that get recomputed on multiple branches — that duplication is the exponential blow-up.
  • The right column is memoized: each value is computed once (green boxes) and its answer stored in the cache table on the right. When a value is needed again, it is a table read (the green arrow into the table), not another subtree.
  • The payoff: the wide tree becomes a straight chain of four computes plus lookups — O(φⁿ) time falls to O(n), paid for with O(n) table space.

In short: Memoization replaces a re-branching tree of duplicate work with one compute per value plus cheap table reads — exponential becomes linear.

Try it
memoization.pyfrom functools import lru_cache
import time

# Naive Fibonacci is exponential: O(phi^n) calls (recomputes the same subproblems).
def fib_slow(n):
    return n if n < 2 else fib_slow(n - 1) + fib_slow(n - 2)

# Memoized: each n computed once -> O(n) time, O(n) space. Requires a PURE function.
@lru_cache(maxsize=None)
def fib_fast(n):
    return n if n < 2 else fib_fast(n - 1) + fib_fast(n - 2)

print("fib_fast(30):", fib_fast(30))      # 832040
print("cache:", fib_fast.cache_info())    # hits/misses show the reuse

# fib_slow(35) is already noticeably slow; fib_fast(100) is instant:
print("fib_fast(100):", fib_fast(100))    # 354224848179261915075
fib_fast(30): 832040
cache: CacheInfo(hits=28, misses=31, maxsize=None, currsize=31)
fib_fast(100): 354224848179261915075
ApproachTimeSpaceNotes
Naive recursion (fib)O(φn) ≈ exponentialO(n) stackRecomputes subproblems
Memoized recursionO(n)O(n) cache + stackPure fn required; hashable args
Bottom-up iterationO(n)O(1) with two varsNo recursion limit at all

8 · Partial application & currying

You do this every day at the coffee shop without thinking. The full "function" is make a drink, which takes a size, a type, and a milk. But you don't re-specify all three each morning — you have "your usual," a version with most of the arguments pre-filled, so you only say what's left. Partial application is that: you take a general function, lock in some of its arguments, and get back a new, more specialized function that needs only the rest. square is just power with the exponent pinned to 2; log_error is just print with the "[ERROR]" prefix pinned. Nothing new is computed — you're just making a convenient, pre-configured handle onto an existing function.

The reason this is worth a section rather than a footnote is that it's the clean answer to a smell you'll otherwise create: passing the same configuration argument through five layers of calls, or writing five nearly-identical wrapper functions that differ by one constant. Instead of threading a rate or a client or a prefix everywhere, you bake it in once with functools.partial and hand around the specialized function. It shines for pre-configuring callbacks (an event system wants a zero-argument function, but your handler needs context — partial supplies the context now, the event supplies the rest later) and for API wrappers where the endpoint or auth is fixed but the payload varies.

Currying is the closely related idea from functional languages: rather than fixing some arguments, you rewrite an n-argument function as a chain of one-argument functions, so f(a, b) becomes f(a)(b) — each call takes one argument and hands back a function waiting for the next. In Python you build it by hand with nested closures (there it is, §1 again). The common confusion to clear up: partial application and currying are not the same thing — partial fixes arguments to shrink the arity, currying reshapes the calling convention into single-argument steps. In practice, Python engineers reach for partial constantly and hand-rolled currying rarely, but knowing both lets you read functional code and pick the lighter tool for the job.

Partial application fixes some of a function's arguments to produce a new, more specialized function. functools.partial is the standard tool — great for pre-configuring callbacks and API wrappers. Currying is the related idea of turning an n-argument function into a chain of one-argument functions, which you can do by hand with nested closures.

partial — pin an argument power(base, exponent) exponent = 2 (pinned) square(base) fewer args, same work curry — one arg at a time add(a, b) add(3) (4) → 7 f(a)(b), each returns a fn Pin vs reshape. Partial application fixes an argument (pin exponent=2 → square), shrinking the arity. Currying reshapes the call into single-argument steps (add(3)(4)), where each call returns a function waiting for the next.
🗺️ How to read this diagram

Read each side top-to-bottom; both start from a two-argument function but do something different to it.

  • The left (partial) side takes power(base, exponent), pins exponent=2 (the purple box), and hands back square(base) — a smaller function that needs only the remaining argument. Nothing is computed yet; you have just pre-filled a slot.
  • The right (curry) side takes add(a, b) and splits the call into steps: add(3) returns a function, and calling that with (4) finally produces 7. The amber arrows trace one argument consumed per step.
  • The payoff: partial changes how many arguments a function takes; currying changes the shape of the calling convention — same result, different tool.

In short: Partial pins arguments to specialize; currying turns f(a, b) into f(a)(b) — one argument at a time.

Try it
partial.pyfrom functools import partial

def power(base, exponent):
    return base ** exponent

# partial application: pre-fill some arguments to make a specialized function.
square = partial(power, exponent=2)
cube   = partial(power, exponent=3)
print("square(5):", square(5))     # 25
print("cube(2):", cube(2))         # 8

# "Currying" by hand: turn f(a, b) into f(a)(b) via nested closures.
def curry_add(a):
    return lambda b: a + b
print("curried:", curry_add(3)(4)) # 7

# partial is common for pre-configuring callbacks / API wrappers:
log_error = partial(print, "[ERROR]")
log_error("disk full")             # [ERROR] disk full
square(5): 25
cube(2): 8
curried: 7
[ERROR] disk full

✓ Checkpoint — you can move on when you can…

  • Explain why [lambda: i for i in range(3)] all return 2, and fix it two ways.
  • Identify a pure vs impure function and rewrite the mutable-default bug safely.
  • Predict whether a tail-recursive function will overflow in CPython, and why.
  • Convert a recursive tree sum into an explicit-stack loop that survives 50k-deep nesting.
  • State the time/space complexity of naive vs memoized Fibonacci and when memoization is valid.
✓ Knowledge check

You register per-item callbacks in a loop: for name in names: register(lambda: handle(name)). At runtime every callback processes the last name. What's wrong and how do you fix it?

Show answer
Late binding: each lambda captures the variable name (a shared cell), not its value at creation time. By the time the callbacks run, the loop has finished and name holds the last value, so all callbacks see it. Fix by binding the current value at definition time: register(lambda name=name: handle(name)), or use a factory def make(n): return lambda: handle(n) and call register(make(name)). Both give each closure its own captured value.
✓ Knowledge check

You memoize a function with @lru_cache and it returns stale or wrong results for some callers. What properties must the function have for memoization to be correct, and what breaks it?

Show answer
Memoization is only sound for pure functions with hashable arguments: same inputs must always mean same output, and results must not depend on time, randomness, external state, or mutable arguments. It breaks when (a) the function reads changing global/DB state, (b) an argument is mutable and later mutated (the cache key no longer reflects the value — and unhashable args like lists raise TypeError), or (c) the result is per-user/per-request. Fixes: only cache the pure core, pass hashable/immutable args (tuples, frozensets), and bound the cache with maxsize. For time/user-varying data, don't cache — or add an explicit expiry.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Build a stateful counter with a closureBeginner

Context: Closures are the simplest way to bundle behavior with private, persistent state — no class needed.

Your task: Write a make_counter() that returns a function; each call increments and returns a private count.

Requirements:

  • Use nonlocal to update the captured count
  • Show two independent counters have separate state
  • Print a short sequence from each

💡 Hint: The count lives in the enclosing scope, not a global.

Show solution
Solution
def make_counter():
    count = 0
    def inc():
        nonlocal count
        count += 1
        return count
    return inc

a, b = make_counter(), make_counter()
print(a(), a(), a())   # 1 2 3
print(b())             # 1  (independent)
1 2 3
1
Exercise 2 · Fix the mutable-default bugIntermediate

Context: The mutable-default-argument bug silently corrupts data across calls and is a favorite interview trap.

Your task: Demonstrate the bug with a function that uses bucket=[], then write the correct version.

Requirements:

  • Show the buggy version accumulating across calls
  • Rewrite using the None sentinel
  • Show the fixed version gives a fresh list each call

💡 Hint: Defaults are created once, at function-definition time.

Show solution
Solution
def buggy(item, bucket=[]):
    bucket.append(item); return bucket
print(buggy(1))   # [1]
print(buggy(2))   # [1, 2]  <- shared!

def fixed(item, bucket=None):
    bucket = [] if bucket is None else bucket
    bucket.append(item); return bucket
print(fixed(1))   # [1]
print(fixed(2))   # [2]
[1]
[1, 2]
[1]
[2]
Exercise 3 · Show CPython has no tail-call optimizationAdvanced

Context: Engineers from Lisp/Scheme backgrounds assume tail recursion is free in every language. In CPython it is not, and proving it prevents a real class of production crashes.

Your task: Write a tail-recursive function and show it raises RecursionError at depth, then give an iterative equivalent that doesn't.

Requirements:

  • A function whose only recursive call is in tail position
  • Call it deep enough to raise RecursionError; catch and report
  • Provide a loop version that returns the correct result for the same depth

💡 Hint: The tail call still allocates a frame — no reuse.

Show solution
Solution
def down(n):
    if n == 0: return 'done'
    return down(n - 1)          # tail call, but a NEW frame

try:
    down(100_000)
except RecursionError:
    print('overflowed: no TCO')

def down_iter(n):
    while n:                     # same logic, constant stack
        n -= 1
    return 'done'
print(down_iter(100_000))        # done
overflowed: no TCO
done
Exercise 4 · Iterative DFS with an explicit stackExpert

Context: Deep or user-supplied tree structures can exceed the recursion limit; production traversals use an explicit stack.

Your task: Convert a recursive depth-first traversal of a nested list into an explicit-stack iterative version that collects leaves in DFS order.

Requirements:

  • Recursive version for reference
  • Iterative version using a list as a stack
  • Show both agree, and that the iterative one handles very deep nesting

💡 Hint: Push children so the pop order matches DFS.

Show solution
Solution
def leaves_rec(x, out=None):
    out = [] if out is None else out
    if isinstance(x, int): out.append(x)
    else:
        for c in x: leaves_rec(c, out)
    return out

def leaves_iter(root):
    out, stack = [], [root]
    while stack:
        node = stack.pop()
        if isinstance(node, int): out.append(node)
        else: stack.extend(reversed(node))   # preserve left-to-right DFS
    return out

t = [1, [2, [3, 4]], 5]
print(leaves_rec(t))    # [1, 2, 3, 4, 5]
print(leaves_iter(t))   # [1, 2, 3, 4, 5]
deep = 1
for _ in range(50_000): deep = [deep]
print(leaves_iter(deep))# [1]  (no RecursionError)
[1, 2, 3, 4, 5]
[1, 2, 3, 4, 5]
[1]
Exercise 5 · Memoize an expensive pure function correctlyProfessional

Context: A recommendation service recomputes an expensive similarity score for repeated (user, item) pairs. Caching helps — but only if done safely.

Your task: Wrap a pure scoring function with lru_cache, prove reuse via cache_info(), and state the conditions that keep it correct.

Requirements:

  • A pure function of two hashable args
  • Bound the cache with maxsize
  • Call with repeated args and print cache_info() showing hits

💡 Hint: Bounded cache + hashable args + purity = safe.

Show solution
Solution
from functools import lru_cache

@lru_cache(maxsize=1024)
def score(user, item):           # pure: depends only on its args
    return (hash((user, item)) % 1000) / 1000.0

for _ in range(3):
    score('u1', 'i1'); score('u1', 'i2')
print(score.cache_info())        # hits=4, misses=2, currsize=2
CacheInfo(hits=4, misses=2, maxsize=1024, currsize=2)
When NOT to cacheIf the score depends on freshness, per-request context, or mutable inputs, caching returns stale/wrong results. Cache only the pure core, use hashable args, bound the size, and add an expiry for time-varying data.
Exercise 6 · Choose functional tools for a data pipeline stageIndustry scenario

Context: You maintain an ingestion stage that (1) normalizes each record, (2) drops invalid ones, (3) combines per-record scores into a total, and (4) is called across many worker processes. A colleague wrote it as one deeply nested recursive function with a shared module-level accumulator list and it crashes on large batches.

Your task: Redesign the stage using pure functions, the right higher-order tools, and safe state, and explain why each choice fixes a specific failure.

Requirements:

  • Replace the shared accumulator with pure functions + a fold
  • Use map/filter (or comprehensions) for transform/validate
  • Explain why this makes the stage parallel-safe and why recursion was the wrong shape

💡 Hint: Purity removes the shared-state crash; a fold replaces the accumulator; iteration removes the depth limit.

Show solution

Why the original failed. A shared module-level accumulator makes the function impure and unsafe across worker processes/threads (races, stale state), and deep recursion over a large batch hits CPython's recursion limit (no TCO) — two independent crashes.

Redesign. Make each step a pure function of one record, compose them with map/filter (or comprehensions) for a flat iterative pass, and combine scores with an explicit reduce fold instead of mutating a shared list. Pure + local state means each worker is independent, so the stage parallelizes safely.

Solution
from functools import reduce

def normalize(rec):            # pure
    return {**rec, 'name': rec['name'].strip().lower()}

def is_valid(rec):             # pure predicate
    return rec.get('score') is not None

def process(batch):
    cleaned = (normalize(r) for r in batch)          # map, lazy
    valid = [r for r in cleaned if is_valid(r)]      # filter
    total = reduce(lambda acc, r: acc + r['score'], valid, 0)  # fold
    return valid, total

batch = [{'name': ' Ada ', 'score': 3}, {'name': 'Bo', 'score': None},
         {'name': 'CE', 'score': 5}]
records, total = process(batch)
print(len(records), total)     # 2 8
2 8
The through-linePure functions are the unit of safe parallelism; a fold (reduce) is the pure way to combine; iteration/comprehensions replace deep recursion. Reach for recursion only when the problem is genuinely tree-shaped and shallow, or convert it to an explicit stack.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in