Recursion in Python: How It Actually Works Under the Hood

Many tutorials teach recursion by showing you a factorial function and telling you that "a function calls itself." That is the surface. Beneath it, Python is allocating frame objects on the call stack, enforcing a hard recursion limit rooted in C stack safety, and deliberately refusing to optimize tail calls -- all for reasons that reflect real design philosophy. This article takes you through the full mechanism, from the frame objects CPython builds on every recursive call, through the PEPs that tried to change how Python handles deep recursion, to the practical patterns -- including trampolining and generator-based approaches -- that experienced Python developers use when recursion reaches its limits.

If you have written Python for any length of time, you have encountered recursion. But there is a gap between "knowing what recursion is" and understanding why your recursive function crashed in production, or why Python's creator has spent over a decade explaining why he will not optimize it. This article closes that gap.

The Call Stack: What Python Actually Does

At its core, recursion is a function calling itself to solve progressively smaller instances of the same problem. Every recursive function needs two things: a base case that stops the recursion, and a recursive case that breaks the problem into a smaller piece and calls the function again.

Here is the classic example, a factorial function:

def factorial(n):
    if n == 0:          # Base case
        return 1
    return n * factorial(n - 1)  # Recursive case

But knowing the definition is not the same as understanding the mechanism. When you call factorial(5), Python does not magically know that 5 * 4 * 3 * 2 * 1 = 120. It builds a chain of frame objects on the call stack, each one waiting for the next to resolve before it can compute its own result.

factorial(5)
  +-- 5 * factorial(4)
            +-- 4 * factorial(3)
                      +-- 3 * factorial(2)
                                +-- 2 * factorial(1)
                                          +-- 1 * factorial(0)
                                                    +-- returns 1

Each of those intermediate calls creates a new frame object that consumes memory and occupies a slot on CPython's interpreter stack. The function at the bottom returns first, and then the stack unwinds, passing results back up the chain. This is not an abstract diagram. It is a physical allocation of resources that has real consequences.

You can observe this directly using Python's inspect module:

import inspect

def show_stack_depth(n):
    if n == 0:
        # Print the number of frames on the call stack
        print(f"Stack depth at base case: {len(inspect.stack())}")
        return 0
    return show_stack_depth(n - 1)

show_stack_depth(10)
# Output: Stack depth at base case: 13
# (10 recursive calls + the initial call + module-level frame + inspect overhead)

What you are actually seeing in that output is the physical evidence of memory allocation. Each frame object in CPython is heap-allocated and contains the function's local variables, a pointer back to the calling frame, the bytecode instruction pointer, and other interpreter state. On a typical 64-bit system, a single frame object consumes roughly 400-500 bytes of memory. At the default recursion limit of 1000, that means a deep recursion chain can consume approximately half a megabyte of Python heap space -- on top of the C stack space consumed by the interpreter itself.

Note

You are not memorizing that recursion means a function calls itself. You are watching the interpreter allocate frame objects and understanding the cost. That is the difference between knowing a concept and being able to reason about it under real constraints.

The Recursion Limit: Why Python Draws a Line

If every recursive call creates a new frame on the call stack, what happens when you recurse ten thousand times? On many systems, the C stack overflows and your program crashes hard, potentially without a clean error message. Python protects you from this with a configurable recursion limit.

import sys
print(sys.getrecursionlimit())  # Default: 1000

The default of 1000 exists for a specific reason. As the CPython documentation for sys.setrecursionlimit explains, this limit prevents infinite recursion from causing an overflow of the C stack and crashing the interpreter. It is a safety net, not a performance tuning parameter.

You can increase it:

sys.setrecursionlimit(5000)
Warning

Raising the recursion limit is not without risk. Setting it too high does not just slow things down. It can cause a segmentation fault that kills your process with no traceback and no recovery. The underlying C stack has a finite, platform-dependent size, and no amount of setrecursionlimit() changes that physical constraint.

# This will likely segfault, not raise RecursionError
sys.setrecursionlimit(10**9)

def deep(n):
    if n == 0:
        return
    deep(n - 1)

deep(10**5)  # Segmentation fault on many platforms

The default C stack size varies significantly by platform. On Linux, it is typically 8 MB (configurable via ulimit -s). On macOS, it is often 8 MB for the main thread but only 512 KB for secondary threads. On Windows, it defaults to 1 MB. This means code that works fine on Linux may segfault on macOS when run from a thread, and crash entirely on Windows -- even with the same setrecursionlimit value. If your code must be portable, staying well under the default limit of 1000 is the safest approach.

The PEPs That Shaped Python's Recursion Behavior

Python's recursion handling has been the subject of several Python Enhancement Proposals, each reflecting evolving thinking about how the interpreter should manage stack depth and safety.

PEP 611 -- The One Million Limit, authored by Mark Shannon and introduced in December 2019, proposed imposing a soft limit of one million on several Python features, including recursion depth. Shannon argued that CPython's existing approach was both inefficient and potentially unsafe. The proposal suggested that a soft limit of one million, with a hard limit of eight million, would allow more efficient internal data packing while remaining far beyond any practical use case. The PEP specified that the recursion depth limit should apply only to pure Python code, since C-level code consumes the hardware stack and is inherently limited to a few thousand frames regardless. PEP 611 was ultimately withdrawn, but it articulated a vision of making Python's limits explicit and useful to implementers.

PEP 651 -- Robust Stack Overflow Handling, also authored by Mark Shannon and created in January 2021, tackled a more fundamental problem. CPython had historically used a single recursion depth counter to prevent both runaway Python recursion and C stack overflow. Shannon argued that these are two different problems that deserve separate solutions. The PEP proposed decoupling the Python stack from the C stack entirely, introducing two new exception classes -- StackOverflow and RecursionOverflow -- both as subclasses of RecursionError. Under this proposal, pure Python-to-Python calls would not consume C stack at all, allowing programs to recurse to whatever depth they needed while still maintaining safety. The Python Steering Council rejected PEP 651 in March 2021, reasoning that deep recursion is not a common pattern in Python and the disruption to debuggers and inspection tools would outweigh the benefits.

Despite that rejection, Python 3.12 implemented a partial version of this separation. Starting with Python 3.12, the recursion limit set by sys.setrecursionlimit() applies only to Python code. Builtin functions and C-level code are now protected by a separate, hardcoded C recursion limit (Py_C_RECURSION_LIMIT). This was a significant practical change, and it introduced subtle behavioral differences. In particular, code combining sys.setrecursionlimit() with C-level recursion -- such as functions decorated with @lru_cache, which is implemented in C -- could behave differently between Python 3.11 and 3.12. This was documented as CPython issue #112282, where the community identified that any recursive Python code using lru_cache with an increased recursion limit could break when upgrading from 3.11 to 3.12.

Why Python Will Never Have Tail Call Optimization

Many functional languages -- Scheme, Haskell, Erlang -- optimize tail-recursive functions so they reuse the current stack frame instead of creating a new one. This means a tail-recursive function can recurse indefinitely without growing the stack. Python deliberately does not do this, and the reasons go deeper than technical limitation.

A function is tail recursive when the recursive call is the very last operation before returning:

# Tail recursive style (not optimized in Python)
def factorial_tail(n, accumulator=1):
    if n == 0:
        return accumulator
    return factorial_tail(n - 1, accumulator * n)

In a language with tail call optimization (TCO), the interpreter would recognize that after the recursive call, nothing else needs to happen in the current frame. It would replace the current frame rather than stacking a new one on top of it.

Guido van Rossum addressed this directly in two blog posts on his Neopythonic blog in April 2009. His objections were both practical and philosophical. First, tail call optimization destroys stack traces: when frames are reused, the traceback no longer shows the full call chain, making it significantly harder to diagnose errors. Second, Van Rossum argued that once TCO exists in CPython, developers will write code that depends on it, and that code will break on alternative Python implementations like PyPy or MicroPython that may not implement the same optimization. Third, and perhaps most revealing, he argued that such code should simply be rewritten using a loop.

Tail call elimination "would certainly confuse many users" who rely on complete stack traces. — Guido van Rossum, paraphrased from "Final Words on Tail Calls" (Neopythonic, April 2009)

In his follow-up post, Van Rossum also noted that TCO advocates agreed with him on one point: TCO is a feature, not merely an optimization. In his view, it works well for some languages, but he did not consider it a good fit for Python's design philosophy, which prioritizes debuggability and explicitness.

This remains Python's official stance. If you need the efficiency of tail recursion, you are expected to refactor into a loop.

# The Pythonic way to handle deep computation
def factorial_iterative(n):
    result = 1
    for i in range(2, n + 1):
        result *= i
    return result

This matters for practical Python development. When you see a RecursionError in production, the answer is almost always to restructure your algorithm, not to increase the recursion limit.

The Python 3.14 Tail Call Interpreter (It Is Not What You Think)

If you have followed recent Python development, you may have seen headlines about a "tail call interpreter" being added to CPython 3.14, which was released on October 7, 2025. This deserves clarification, because the name is misleading.

The new interpreter, contributed by Ken Jin and merged in February 2025, uses tail calls between small C functions that implement individual Python opcodes, replacing the traditional large C switch statement that was over 12,000 lines long. As the Python 3.14 release notes explicitly state, this is not to be confused with tail call optimization of Python functions, which is currently not implemented in CPython.

The performance story behind this change is itself a lesson in careful measurement. The initial benchmarks suggested a dramatic 10-15% speedup, and the change was merged during that euphoric period. However, as independent researcher Nelson Elhage documented in March 2025, the impressive gains were largely an artifact of a bug in LLVM/Clang 19 that had silently degraded the performance of the previous computed-goto interpreter. When benchmarked against a proper baseline (such as Clang 18 or GCC), the actual improvement was in the 3-5% range. Ken Jin published a transparent correction, and the official release notes were updated accordingly.

Pro Tip

The Python 3.14 "tail call interpreter" optimizes how CPython's own C code dispatches bytecode instructions. By splitting the massive interpreter function into individual C functions per opcode, the compiler can optimize each function independently, avoid register allocation problems that plagued the monolithic switch statement, and reduce susceptibility to compiler bugs. Your recursive Python functions still create frame objects, still consume stack space, and still hit the recursion limit exactly as before. As of Python 3.15 development (December 2025), Ken Jin has reported that MSVC support on Windows yields 15-20% speedups over the switch-case interpreter, making this optimization increasingly significant across platforms.

Real Recursive Algorithms That Actually Matter

Enough with factorial. Let us look at recursion where it earns its keep: problems with inherently recursive structure.

Traversing nested data structures is where recursion shines. Consider flattening an arbitrarily nested list:

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

nested = [1, [2, [3, 4], 5], [6, [7, [8]]]]
print(flatten(nested))
# Output: [1, 2, 3, 4, 5, 6, 7, 8]

The structure of the data is recursive (lists within lists within lists), so a recursive solution maps naturally onto the problem. An iterative version would require maintaining an explicit stack, and the code would be harder to read.

Tree traversal is another classic. Here is a depth-first search on a binary tree:

class TreeNode:
    def __init__(self, val, left=None, right=None):
        self.val = val
        self.left = left
        self.right = right

def inorder(node):
    if node is None:
        return []
    return inorder(node.left) + [node.val] + inorder(node.right)

#        4
#       / \
#      2   6
#     / \ / \
#    1  3 5  7
tree = TreeNode(4,
    TreeNode(2, TreeNode(1), TreeNode(3)),
    TreeNode(6, TreeNode(5), TreeNode(7))
)

print(inorder(tree))
# Output: [1, 2, 3, 4, 5, 6, 7]

Directory walking is a real-world use case. While os.walk exists, understanding the recursive version reveals what that function does internally:

import os

def find_python_files(directory):
    py_files = []
    for entry in os.scandir(directory):
        if entry.is_dir(follow_symlinks=False):
            py_files.extend(find_python_files(entry.path))
        elif entry.name.endswith('.py'):
            py_files.append(entry.path)
    return py_files

Parsing nested expressions is another domain where recursion is natural. Here is a simple recursive descent parser for balanced parentheses:

def parse_parens(s, index=0):
    """Parse nested parentheses and return the nesting structure."""
    result = []
    while index < len(s):
        if s[index] == '(':
            children, index = parse_parens(s, index + 1)
            result.append(children)
        elif s[index] == ')':
            return result, index + 1
        else:
            result.append(s[index])
            index += 1
    return result, index

parsed, _ = parse_parens("(a(bc)d)")
print(parsed)
# Output: [['a', ['b', 'c'], 'd']]

Memoization: Making Recursion Practical

Naive recursion can be catastrophically slow. The textbook Fibonacci implementation has exponential time complexity because it recomputes the same values over and over:

def fib_naive(n):
    if n <= 1:
        return n
    return fib_naive(n - 1) + fib_naive(n - 2)

# fib_naive(35) makes over 29 million function calls
# (precisely 29,860,703 -- the formula is 2*fib(n+1) - 1)

Python's functools.lru_cache transforms this from exponential to linear by caching results:

from functools import lru_cache

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

print(fib(100))
# Output: 354224848179261915075
# Computed almost instantly

Beware the interaction between lru_cache and the recursion limit, especially across Python versions. Since lru_cache is implemented in C, it adds C-level stack frames on top of your Python frames. After the Python 3.12 changes that separated Python and C stack limits (via the hardcoded Py_C_RECURSION_LIMIT), code that worked with a custom setrecursionlimit in Python 3.11 could unexpectedly hit RecursionError in 3.12, as documented in CPython issue #112282. If you are computing fib(500) with lru_cache, you may need to warm up the cache iteratively:

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

# Warm up in small batches to avoid hitting the recursion limit
for i in range(0, 2000, 100):
    fib(i)

# Now deep calls are served from cache
print(fib(1999))

Also note the difference between @lru_cache(maxsize=None) and @functools.cache. The @cache decorator, introduced in Python 3.9, is equivalent to lru_cache(maxsize=None) but has a simpler implementation and slightly less overhead. For memoized recursive functions where you want an unbounded cache, @cache is the preferred choice in modern Python.

Converting Recursion to Iteration: A Practical Skill

In his influential 1974 paper "Structured Programming with go to Statements" (published in ACM Computing Surveys, Volume 6, Number 4, pages 261-301), Donald Knuth wrote that he had always considered the transformation between recursion and iteration to be among the most fundamental ideas in computer science. Understanding this transformation is what separates someone who knows recursion from someone who can wield it.

The key insight: any recursion can be converted to iteration by explicitly managing a stack. Here is the tree inorder traversal rewritten iteratively:

def inorder_iterative(root):
    result = []
    stack = []
    current = root

    while current or stack:
        # Go as far left as possible
        while current:
            stack.append(current)
            current = current.left

        # Process the node
        current = stack.pop()
        result.append(current.val)

        # Move to the right subtree
        current = current.right

    return result

The recursive version is more readable. The iterative version uses constant stack space regardless of tree depth. The right choice depends on your constraints. If your tree might have a million nodes and is skewed (essentially a linked list), the iterative version will work while the recursive version will crash.

For tail recursion specifically, the conversion is mechanical. Replace the recursive call with a loop and reassign the parameters:

# Tail recursive (not optimized in Python)
def gcd_recursive(a, b):
    if b == 0:
        return a
    return gcd_recursive(b, a % b)

# Equivalent iterative version
def gcd_iterative(a, b):
    while b != 0:
        a, b = b, a % b
    return a

Trampolining: Stack-Safe Recursion Without Giving Up the Pattern

What if you have a recursive algorithm that you cannot easily restructure into a loop -- say, mutually recursive functions or a complex state machine -- but it exceeds Python's recursion limit? Trampolining is the answer that many Python developers never learn about.

A trampoline is a loop that repeatedly calls a function. Instead of making a recursive call directly, the function returns a thunk -- a zero-argument callable that represents the deferred recursive step. The trampoline loop keeps calling thunks until it gets a final result:

def trampoline(fn, *args):
    """Execute a trampolined function without growing the stack."""
    result = fn(*args)
    while callable(result):
        result = result()
    return result

def factorial_tramp(n, acc=1):
    if n == 0:
        return acc
    # Return a thunk instead of making a recursive call
    return lambda: factorial_tramp(n - 1, acc * n)

# This works for arbitrarily large n without RecursionError
print(trampoline(factorial_tramp, 10000))  # Computes 10000! without stack overflow

Trampolining also works for mutually recursive functions, which are significantly harder to convert to simple loops:

def is_even_tramp(n):
    if n == 0:
        return True
    return lambda: is_odd_tramp(n - 1)

def is_odd_tramp(n):
    if n == 0:
        return False
    return lambda: is_even_tramp(n - 1)

# Works for any depth
print(trampoline(is_even_tramp, 100000))  # True
print(trampoline(is_odd_tramp, 100000))   # False

The trampoline trades stack space for heap space (each thunk is a closure allocated on the heap) and incurs the overhead of creating and calling lambda functions. In practice, the overhead is modest for many applications, and it is far cheaper than a segmentation fault. This pattern is borrowed from the Scheme and Clojure communities, where it has been a standard technique for decades.

Note

The trampoline pattern is not just a theoretical curiosity. Libraries like toolz (a popular functional programming toolkit for Python) provide trampoline utilities. If you find yourself reaching for sys.setrecursionlimit(), consider reaching for a trampoline instead.

Generator-Based Recursion: A Pythonic Alternative

Python's generators offer another way to express recursive logic without consuming the call stack. Instead of building up stack frames, a generator yields intermediate results lazily, and the caller drives the iteration:

def flatten_gen(data):
    """Flatten arbitrarily nested lists using a generator."""
    for item in data:
        if isinstance(item, list):
            yield from flatten_gen(item)
        else:
            yield item

nested = [1, [2, [3, 4], 5], [6, [7, [8]]]]
print(list(flatten_gen(nested)))
# Output: [1, 2, 3, 4, 5, 6, 7, 8]

The yield from syntax (introduced in Python 3.3 via PEP 380) delegates to a sub-generator. This is still recursive -- it still builds stack frames -- but the lazy evaluation means you can process elements one at a time without materializing the entire result in memory. For data processing pipelines, this can reduce memory usage from O(n) to O(1) for the output, though the recursion depth is still bounded by the nesting depth of the input.

For truly stack-safe generator-based recursion, you can combine generators with an explicit stack:

def flatten_stacksafe(data):
    """Fully stack-safe flattening using an explicit stack of iterators."""
    stack = [iter(data)]
    while stack:
        try:
            item = next(stack[-1])
            if isinstance(item, list):
                stack.append(iter(item))
            else:
                yield item
        except StopIteration:
            stack.pop()

# Works regardless of nesting depth
deeply_nested = [1]
for _ in range(10000):
    deeply_nested = [deeply_nested, 2]

# The recursive generator would crash; this does not
result = list(flatten_stacksafe(deeply_nested))

This pattern -- replacing recursive generator calls with an explicit stack of iterators -- is what Python's own os.walk does under the hood. It is one of the more Pythonic ways to handle potentially unbounded recursive data structures.

Debugging Recursive Functions in Practice

When recursion goes wrong in production, the error messages can be overwhelming. A RecursionError traceback prints the same line hundreds of times, which obscures the actual state that caused the problem. Here are concrete techniques for making recursive functions debuggable.

Add a depth parameter to your recursive function during development. This gives you a built-in tripwire and makes the traceback more informative:

def search(node, target, depth=0, max_depth=500):
    if depth > max_depth:
        raise ValueError(
            f"Search exceeded {max_depth} levels. "
            f"Current node: {node}, target: {target}"
        )
    if node is None:
        return None
    if node.val == target:
        return node
    return (search(node.left, target, depth + 1, max_depth) or
            search(node.right, target, depth + 1, max_depth))

Use sys.setrecursionlimit defensively. Instead of raising the global limit, catch the error and provide actionable diagnostics:

import sys

def safe_recursive_process(data):
    try:
        return process(data)
    except RecursionError:
        # Provide actionable information
        depth = estimate_depth(data)
        current_limit = sys.getrecursionlimit()
        raise RuntimeError(
            f"Data structure too deep for recursion "
            f"(estimated depth: {depth}, limit: {current_limit}). "
            f"Consider using an iterative approach."
        ) from None

Visualize the recursion tree for small inputs before deploying. This trace decorator prints the call tree with indentation:

def trace(fn):
    """Decorator that prints a visual trace of recursive calls."""
    trace.depth = 0
    def wrapper(*args):
        print(f"{'  ' * trace.depth}{fn.__name__}({', '.join(map(repr, args))})")
        trace.depth += 1
        result = fn(*args)
        trace.depth -= 1
        print(f"{'  ' * trace.depth}-> {result}")
        return result
    return wrapper

@trace
def fib(n):
    if n <= 1:
        return n
    return fib(n - 1) + fib(n - 2)

fib(4)
# fib(4)
#   fib(3)
#     fib(2)
#       fib(1)
#       -> 1
#       fib(0)
#       -> 0
#     -> 1
#     fib(1)
#     -> 1
#   -> 2
#   fib(2)
#     fib(1)
#     -> 1
#     fib(0)
#     -> 0
#   -> 1
# -> 3

Space Complexity: The Hidden Cost Nobody Warns You About

Time complexity gets discussed extensively. Space complexity in the context of recursion does not. Every recursive call consumes stack space proportional to the size of the local variables in that frame. For many algorithms, this means the space complexity equals the recursion depth.

Consider merge sort. Its time complexity is O(n log n), which every textbook covers. But its space complexity when implemented recursively is O(n) for the auxiliary arrays plus O(log n) for the stack frames. That stack component is rarely mentioned, but in Python it is a real constraint. A balanced binary tree with a million nodes has a depth of about 20, which is well within the recursion limit. A skewed tree with the same number of nodes has a depth of one million, which will crash immediately.

The computer scientist Joe Marshall made an incisive argument about this in a 2009 response to Guido van Rossum's blog posts on tail recursion. Marshall argued that refusing to optimize tail calls means the space complexity of programs is not determined by the algorithm's actual data dependencies, but by the structure of the call graph -- an implementation detail that should ideally be invisible to the programmer. He compared this to the difference between bubble sort and more efficient sorting algorithms: poor asymptotic behavior that affects the entire program. (Source: Joe Marshall, "You knew I'd say something," funcall.blogspot.com, April 2009.)

Whether you agree with Marshall or with Van Rossum, the practical takeaway is this: when you analyze a recursive algorithm in Python, always consider its space cost, and remember that in Python, that cost is always at least O(depth), never O(1), regardless of whether the recursion is in tail position.

When to Use Recursion in Python: A Decision Framework

After all of this, here is the practical guidance.

Use recursion when the problem has inherently recursive structure (trees, nested data, divide-and-conquer algorithms), the maximum depth is bounded and well within 1000, or the recursive solution is dramatically clearer than the iterative alternative.

Avoid recursion when the depth could exceed a few hundred calls, you are processing linear sequences (use a loop), performance is critical and the function is called millions of times, or you find yourself reaching for sys.setrecursionlimit() as a first instinct.

Always consider memoization when your recursive function has overlapping subproblems. Python's @lru_cache or @cache makes this trivial and can transform an unusable algorithm into an efficient one.

Reach for trampolining or explicit stacks when you need the elegance of recursion but your depth is unbounded. These patterns are more idiomatic than setrecursionlimit and safer than hoping the C stack will hold.

Use generators for recursive data processing when you need lazy evaluation. The yield from pattern reads almost identically to standard recursion but gives you the memory benefits of streaming.

Pro Tip

Van Rossum designed Python with the expectation that iteration is the default. Recursion is a tool in the toolbox, not the first thing you reach for. When you do use it, understand the stack cost, respect the recursion limit, and write a base case that you can prove will always be reached.

Key Takeaways

  1. Recursion has a physical cost: Every recursive call allocates a frame object on the heap and consumes C stack space. The default limit of 1000 exists to prevent C stack overflow, not to annoy you. Exceeding it does not raise a polite error -- it can segfault your process.
  2. Python deliberately refuses tail call optimization: This is a design decision, not a missing feature. Van Rossum prioritized debuggability and implementation portability over enabling deep recursion. The 2009 blog posts and the rejection of PEP 651 in 2021 confirm this stance is stable.
  3. The Python 3.14 "tail call interpreter" is unrelated to your recursive functions: It optimizes CPython's internal bytecode dispatch in C, yielding 3-5% general performance gains. Your Python recursion depth, frame allocation, and stack limits are entirely unaffected.
  4. You have more tools than just "recurse or loop": Memoization, trampolining, generator-based recursion, and explicit stack management all solve different aspects of the recursion problem. The right tool depends on whether your bottleneck is depth, repeated computation, memory, or readability.
  5. Cross-version and cross-platform behavior varies: The Python 3.12 separation of Python and C recursion limits can break code that worked in 3.11, especially with @lru_cache. C stack sizes differ by platform and thread. Test your recursive code on the same Python version and OS you deploy to.

Recursion is not magic. It is a function calling itself, building stack frames, and unwinding them. Once you see it that way -- with real comprehension of the frame objects, the C stack constraints, and the design philosophy that shapes Python's behavior -- you can make informed decisions about when it serves your code and when it does not. And when recursion reaches its limits, you now have the patterns to go beyond them.

back to articles