Recursion in Python Explained — How It Works, When to Use It, and When to Avoid It
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.
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)
--- 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
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.
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(".")
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)
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.
# 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.")
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.
| Aspect | Recursion | Iteration (Loop) |
|---|---|---|
| Best fit for | Tree-shaped / self-similar data | Linear / sequential data |
| Code readability | Mirrors the problem structure naturally | Familiar and explicit |
| Memory usage | O(depth) stack frames — can hit limit | O(1) extra memory for simple loops |
| Python stack limit | Yes — crashes at ~1000 frames by default | No limit |
| Tail-call optimisation | Not supported in Python | Not applicable |
| Performance (naive) | Can be exponential without memoisation | Typically linear or better |
| Easy fix for perf issues | Add @lru_cache in one line | Manual optimisation |
| Error tracing | Deep tracebacks, harder to read | Shorter, simpler tracebacks |
| Real use cases | File systems, trees, parsers, graphs | List 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_cachedecorator 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 offactorial(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.
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.