Home Python Recursion in Python Explained — How It Works, When to Use It, and When to Avoid It

Recursion in Python Explained — How It Works, When to Use It, and When to Avoid It

In Plain English 🔥
Imagine you're looking for your keys in a backpack. You open it and find another smaller bag inside. You open that and find another bag inside that. You keep opening bags until you find one with no bags — just the keys. That innermost bag is the 'base case', and each time you opened a bag is one recursive call. Recursion is just a function that solves a big problem by repeatedly solving smaller versions of the same problem, until it hits a version so small it can answer immediately.
⚡ Quick Answer
Imagine you're looking for your keys in a backpack. You open it and find another smaller bag inside. You open that and find another bag inside that. You keep opening bags until you find one with no bags — just the keys. That innermost bag is the 'base case', and each time you opened a bag is one recursive call. Recursion is just a function that solves a big problem by repeatedly solving smaller versions of the same problem, until it hits a version so small it can answer immediately.

Recursion sounds academic until you're staring at a problem that has no clean iterative solution — parsing a nested JSON structure, walking a file system, or solving a tree traversal. At that point, a recursive function is not just elegant, it's the natural language the problem is written in. Real codebases use recursion constantly: Django's template engine, Python's ast module, and virtually every algorithm that operates on trees or graphs rely on it.

The problem recursion solves is 'self-similar structure'. Some problems contain smaller versions of themselves — the classic example being a folder that contains other folders. Writing a loop to handle arbitrarily deep nesting is painful and brittle. A recursive function handles it gracefully because it says: 'I know how to process one folder. If there are subfolders, I'll just call myself on each of them.' The logic stays simple no matter how deeply nested the data gets.

By the end of this article you'll understand exactly how Python executes recursive calls using the call stack, why a missing base case crashes your program, how to write clean recursive solutions for real problems like tree traversal and directory scanning, and — critically — when recursion is the wrong tool and iteration is the better choice.

How Python Actually Executes a Recursive Function — The Call Stack

Every time you call a function in Python, the interpreter pushes a 'frame' onto the call stack — a small record containing the function's local variables and where to return when it finishes. When a function calls itself recursively, a brand new frame is pushed on top. This keeps happening until the base case is hit. Then each frame resolves from the top down, like peeling layers off an onion.

This is the mental model that makes recursion click. You're not replacing the current call — you're stacking a new one on top of it. The original call is patiently waiting, paused mid-execution, until the deeper calls resolve and hand their results back up the chain.

Python's default stack limit is 1000 frames. That's not a bug — it's a safety net. Without it, infinite recursion would silently eat all your RAM. Once you understand the stack, you understand why recursion has that limit, why it uses more memory than a loop, and why tail-call optimisation (which erases finished frames) is something Python deliberately doesn't implement — Guido van Rossum wanted tracebacks to stay readable.

call_stack_visualised.py · PYTHON
1234567891011121314151617181920212223242526
import sys

def countdown(current_number):
    """
    Counts down from current_number to 1, then prints 'Go!'.
    Each recursive call gets its own 'current_number' variable on the stack.
    """
    # BASE CASE — the simplest version of the problem we can answer directly.
    # Without this, the function would call itself forever and hit RecursionError.
    if current_number == 0:
        print("Go!")
        return  # stops the recursion and starts unwinding the stack

    # RECURSIVE CASE — we print the current number, then ask the function
    # to handle the rest of the countdown (current_number - 1).
    print(f"Stack depth: {current_number} | Calling countdown({current_number - 1})")
    countdown(current_number - 1)

    # This line runs AFTER the recursive call returns.
    # It proves the original call was paused, not destroyed.
    print(f"Stack depth: {current_number} | Back from countdown({current_number - 1}) — unwinding")

print(f"Python's default recursion limit: {sys.getrecursionlimit()}")
print("--- Starting countdown ---")
countdown(3)
▶ Output
Python's default recursion limit: 1000
--- Starting countdown ---
Stack depth: 3 | Calling countdown(2)
Stack depth: 2 | Calling countdown(1)
Stack depth: 1 | Calling countdown(0)
Go!
Stack depth: 1 | Back from countdown(0) — unwinding
Stack depth: 2 | Back from countdown(1) — unwinding
Stack depth: 3 | Back from countdown(2) — unwinding
🔥
The Golden Rule of Recursion:Every recursive function needs exactly two things: (1) a base case that returns without calling itself, and (2) a recursive case that moves closer to the base case with every call. If your recursive case doesn't get closer to the base case, you have infinite recursion — full stop.

Writing Real Recursive Solutions — Factorial, Fibonacci, and File Systems

Let's move from theory into three progressively real examples. Factorial is the classic teaching example — but we'll use it to lock in the base case / recursive case pattern. Fibonacci shows the classic performance trap beginners fall into. The file system walker is what recursion actually looks like in production code.

Factorial of n (written n!) means n × (n-1) × (n-2) × ... × 1. The definition itself is recursive: n! = n × (n-1)!. That makes the recursive solution feel like translating English directly into Python.

Fibonacci shows something important: naive recursion can be catastrophically slow because it recalculates the same values dozens of times. We'll compare the naive version with a memoised version using functools.lru_cache — a one-line fix that transforms it from toy code into something usable. The file system example is the payoff: showing you a real task where recursion is objectively the cleanest tool.

recursion_real_examples.py · PYTHON
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
import os
import functools
import time

# ─────────────────────────────────────────────
# EXAMPLE 1: Factorial — the pattern template
# ─────────────────────────────────────────────
def factorial(number):
    """
    Returns the factorial of a non-negative integer.
    factorial(5) → 5 × 4 × 3 × 2 × 1 = 120
    """
    if number < 0:
        raise ValueError(f"Factorial is not defined for negative numbers, got {number}")

    # Base case: 0! and 1! are both defined as 1
    if number <= 1:
        return 1

    # Recursive case: n! = n × (n-1)!
    # We trust that factorial(number - 1) will return the right answer —
    # this trust is what makes recursion work. Focus on one level at a time.
    return number * factorial(number - 1)

print("=== Factorial ===")
for n in [0, 1, 5, 10]:
    print(f"  factorial({n}) = {factorial(n)}")


# ─────────────────────────────────────────────
# EXAMPLE 2: Fibonacci — naive vs memoised
# ─────────────────────────────────────────────
def fibonacci_naive(position):
    """
    Returns the Fibonacci number at the given position.
    fibonacci(0)=0, fibonacci(1)=1, fibonacci(6)=8
    WARNING: exponential time complexity O(2^n). Melts for n > 35.
    """
    if position <= 0:
        return 0
    if position == 1:
        return 1
    # This creates a binary tree of calls. fibonacci(6) calls fibonacci(5)
    # AND fibonacci(4). fibonacci(5) also calls fibonacci(4). That's wasteful.
    return fibonacci_naive(position - 1) + fibonacci_naive(position - 2)

# @lru_cache stores results so we never compute the same position twice.
# This converts the time complexity from O(2^n) to O(n). One decorator, massive gain.
@functools.lru_cache(maxsize=None)
def fibonacci_memoised(position):
    """Same logic as naive, but results are cached after the first call."""
    if position <= 0:
        return 0
    if position == 1:
        return 1
    return fibonacci_memoised(position - 1) + fibonacci_memoised(position - 2)

print("\n=== Fibonacci: Naive vs Memoised ===")
test_position = 35

start = time.perf_counter()
naive_result = fibonacci_naive(test_position)
naive_duration = time.perf_counter() - start

start = time.perf_counter()
memo_result = fibonacci_memoised(test_position)
memo_duration = time.perf_counter() - start

print(f"  fibonacci({test_position}) = {naive_result}")
print(f"  Naive time:     {naive_duration:.4f}s")
print(f"  Memoised time:  {memo_duration:.6f}s")
print(f"  Speedup:        ~{naive_duration / memo_duration:.0f}x faster")


# ─────────────────────────────────────────────
# EXAMPLE 3: Recursive file system walk
# This is real production-style recursion.
# ─────────────────────────────────────────────
def list_python_files(directory_path, indent_level=0):
    """
    Recursively lists all .py files under directory_path,
    showing folder structure with indentation.
    """
    indent = "  " * indent_level  # visual depth indicator

    try:
        entries = os.listdir(directory_path)
    except PermissionError:
        print(f"{indent}[Permission denied: {directory_path}]")
        return  # graceful base case — can't go deeper, so stop

    for entry_name in sorted(entries):
        full_path = os.path.join(directory_path, entry_name)

        if os.path.isdir(full_path):
            # It's a folder — recurse into it, one level deeper
            print(f"{indent}📁 {entry_name}/")
            list_python_files(full_path, indent_level + 1)

        elif entry_name.endswith(".py"):
            # It's a Python file — base case, just print it
            size_kb = os.path.getsize(full_path) / 1024
            print(f"{indent}  🐍 {entry_name} ({size_kb:.1f} KB)")

print("\n=== Python Files in Current Directory ===")
list_python_files(".")
▶ Output
=== Factorial ===
factorial(0) = 1
factorial(1) = 1
factorial(5) = 120
factorial(10) = 3628800

=== Fibonacci: Naive vs Memoised ===
fibonacci(35) = 9227465
Naive time: 2.8431s
Memoised time: 0.000021s
Speedup: ~135428x faster

=== Python Files in Current Directory ===
📁 examples/
🐍 call_stack_visualised.py (0.8 KB)
🐍 recursion_real_examples.py (2.1 KB)
⚠️
Pro Tip: The 'Leap of Faith' TechniqueWhen writing a recursive function, don't try to trace every call in your head — that way madness lies. Instead, assume the function already works correctly for smaller inputs. Your only job is to write the one-step reduction: 'if I had the answer for n-1, how do I get the answer for n?' Write that, add the base case, and trust the stack to do the rest.

Recursion vs Iteration — Choosing the Right Tool Without Dogma

Recursion and iteration are equally powerful — any recursive function can be rewritten as a loop with an explicit stack, and vice versa. The real question is: which version is easier to read, maintain, and reason about for this specific problem?

Recursion wins when the data structure is inherently recursive — trees, graphs, nested lists, file systems, HTML/XML parsing. The recursive solution mirrors the shape of the data. The iterative version would require you to manually maintain a stack, which is just reimplementing what Python does for you automatically.

Iteration wins for linear sequences. Summing a list, processing rows in a CSV, generating a range of numbers — these are flat, sequential operations. Writing them recursively adds overhead and a risk of hitting the recursion limit with no benefit. Python also lacks tail-call optimisation, which means even a theoretically 'tail-recursive' function doesn't get the memory savings it would in Haskell or Scheme.

The honest rule: if you can draw the problem as a tree, recursion probably fits. If it's a straight line, use a loop.

recursion_vs_iteration.py · PYTHON
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879
# Comparing recursive and iterative approaches for two problems.
# Problem A: Sum a flat list — iteration wins clearly.
# Problem B: Flatten a deeply nested list — recursion wins clearly.

from typing import Any

# ─────────────────────────────────────────────
# PROBLEM A: Summing a flat list
# ─────────────────────────────────────────────
def sum_recursive(number_list):
    """Recursive sum — technically works, but awkward for a flat list."""
    if not number_list:          # base case: empty list sums to zero
        return 0
    # Take the first element, add it to the sum of everything else.
    # Python creates a new list slice on every call — that's O(n²) memory.
    return number_list[0] + sum_recursive(number_list[1:])

def sum_iterative(number_list):
    """Iterative sum — this is how you should actually do it."""
    running_total = 0
    for number in number_list:   # O(n) time, O(1) extra memory
        running_total += number
    return running_total

sample_numbers = [10, 20, 30, 40, 50]
print("=== Flat List Sum ===")
print(f"  Recursive: {sum_recursive(sample_numbers)}")   # works, but wasteful
print(f"  Iterative: {sum_iterative(sample_numbers)}")   # clean and efficient
print(f"  Built-in:  {sum(sample_numbers)}")             # just use this in real life


# ─────────────────────────────────────────────
# PROBLEM B: Flattening an arbitrarily nested list
# ─────────────────────────────────────────────
def flatten_nested_list(nested: Any) -> list:
    """
    Converts an arbitrarily nested list into a flat list.
    flatten_nested_list([1, [2, [3, 4]], 5]) → [1, 2, 3, 4, 5]

    The iterative version of this requires manually managing a stack.
    The recursive version just... describes what flat means.
    """
    flat_result = []

    for item in nested:
        if isinstance(item, list):
            # This item is itself a list — recurse into it.
            # We don't know how deep it goes, and we don't need to.
            flat_result.extend(flatten_nested_list(item))
        else:
            # Base case: it's a plain value, just collect it.
            flat_result.append(item)

    return flat_result

weirdly_nested = [1, [2, 3], [4, [5, [6, 7]]], 8, [9, [10]]]
print("\n=== Flatten Nested List ===")
print(f"  Input:  {weirdly_nested}")
print(f"  Output: {flatten_nested_list(weirdly_nested)}")

# For contrast, here's what the iterative version looks like.
# It's correct, but you have to manage the stack manually — more surface area for bugs.
def flatten_iterative(nested: Any) -> list:
    """Iterative flatten using an explicit stack. Compare complexity with above."""
    flat_result = []
    stack = list(nested)         # seed the stack with top-level items

    while stack:
        item = stack.pop(0)      # take from the front to preserve order
        if isinstance(item, list):
            stack = list(item) + stack   # push inner items back onto the front
        else:
            flat_result.append(item)

    return flat_result

print(f"  Iterative output: {flatten_iterative(weirdly_nested)}")
print("  Both give the same result. Recursive version is easier to reason about.")
▶ Output
=== Flat List Sum ===
Recursive: 150
Iterative: 150
Built-in: 150

=== Flatten Nested List ===
Input: [1, [2, 3], [4, [5, [6, 7]]], 8, [9, [10]]]
Output: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
Iterative output: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
Both give the same result. Recursive version is easier to reason about.
⚠️
Watch Out: Recursion Depth on Real DataIf you're recursing over user-provided data — like a nested JSON payload from an API — you don't control how deep it goes. A malicious or malformed payload with 1001 levels of nesting will crash your app with RecursionError. Always add a max_depth parameter or switch to an iterative approach with an explicit stack for production code that handles untrusted input.
AspectRecursionIteration (Loop)
Best fit forTree-shaped / self-similar dataLinear / sequential data
Code readabilityMirrors the problem structure naturallyFamiliar and explicit
Memory usageO(depth) stack frames — can hit limitO(1) extra memory for simple loops
Python stack limitYes — crashes at ~1000 frames by defaultNo limit
Tail-call optimisationNot supported in PythonNot applicable
Performance (naive)Can be exponential without memoisationTypically linear or better
Easy fix for perf issuesAdd @lru_cache in one lineManual optimisation
Error tracingDeep tracebacks, harder to readShorter, simpler tracebacks
Real use casesFile systems, trees, parsers, graphsList processing, counters, pipelines

🎯 Key Takeaways

  • Every recursive function needs a base case that returns without calling itself, and a recursive case that provably moves closer to that base case on every call — skip either and you get RecursionError.
  • Python pushes a new stack frame for every recursive call — the default limit is 1000, which is a deliberate design choice to keep tracebacks readable, not a bug to work around for normal use.
  • Naive recursive Fibonacci is O(2^n) and will time out on inputs above ~40 — a single @functools.lru_cache decorator converts it to O(n) with zero logic changes.
  • Use recursion when the data is shaped like a tree (file systems, nested configs, ASTs, graphs); use iteration for flat sequences — the right choice is about matching the tool to the shape of the problem, not about which feels more clever.

⚠ Common Mistakes to Avoid

  • Mistake 1: Forgetting the base case entirely — The function calls itself forever and Python raises RecursionError: maximum recursion depth exceeded. Fix: always write the base case first, before any recursive logic, and check it handles every exit condition (not just the happy path).
  • Mistake 2: The base case exists but the recursive call doesn't actually move toward it — e.g., calling factorial(number) instead of factorial(number - 1). The symptom is the same RecursionError, but it's trickier to spot. Fix: print the argument on each call during debugging and confirm it shrinks every single time.
  • Mistake 3: Using naive recursion on Fibonacci or similar overlapping-subproblem functions — fibonacci(40) takes several seconds; fibonacci(50) may never finish. Fix: decorate with @functools.lru_cache(maxsize=None) for a one-line memoisation that makes it run in microseconds. For problems you control completely, also consider converting to a bottom-up dynamic programming loop.

Interview Questions on This Topic

  • QWhat's the difference between a base case and a recursive case, and what happens in Python if you omit the base case?
  • QWhy doesn't Python support tail-call optimisation, and how would you work around the default recursion limit of 1000 for a legitimately deep recursive problem?
  • QGiven a naive recursive Fibonacci implementation that runs in O(2^n) time, how would you optimise it — and can you name two different approaches with their tradeoffs?

Frequently Asked Questions

What is recursion in Python and when should I use it?

Recursion is when a function calls itself to solve a smaller version of the same problem, repeating until it hits a base case it can answer directly. Use it when the problem has a self-similar or tree-shaped structure — like walking a file system, parsing nested data, or tree traversal. For flat, linear problems, a regular loop is faster and simpler.

How do I fix RecursionError: maximum recursion depth exceeded in Python?

This error means your function called itself more than Python's limit (default 1000). There are three fixes: (1) check your base case is reachable and your recursive case genuinely approaches it, (2) add memoisation with @lru_cache if you're recomputing the same inputs, or (3) rewrite using an iterative approach with an explicit stack. Raising the limit with sys.setrecursionlimit() is a last resort — it doesn't solve the underlying problem.

What is a base case in recursion and why is it essential?

A base case is the condition where the recursive function returns an answer directly without calling itself again. It's the stopping point. Without it, the function would call itself forever until Python's stack fills up and throws a RecursionError. Think of it as the floor of a building — every staircase needs somewhere to end.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousClosures in PythonNext →map filter and reduce in Python
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged