Recursion in Python: A Complete Guide with Examples

Recursion in Python is when a function calls itself to solve a smaller version of the same problem. Every recursive function needs a base case that stops the calls and a recursive case that moves toward it; without a base case Python stops with RecursionError after about 1,000 calls. This guide shows how Python recursion works on the call stack, the recursion limit, how memoization with lru_cache makes recursive functions fast, practical examples (nested lists, folders, binary search, permutations) and when a loop is the better choice, with every example run and its real output.

All examples were run with Python 3.12.5 and Matplotlib 3.11.2 in the Windows Command Prompt. Reference: sys.getrecursionlimit() and functools.lru_cache in the Python docs.

A simple recursive function

def countdown(n):
    if n == 0:                  # base case: stop here
        print("Liftoff!")
        return
    print(n)
    countdown(n - 1)            # recursive case: a smaller problem

countdown(3)


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

print("5! =", factorial(5))

Output:

3
2
1
Liftoff!
5! = 120

countdown(3) prints 3 and calls countdown(2), which calls countdown(1), and so on until n == 0, the base case. factorial() works the same way but combines the results on the way back: 5 × 4 × 3 × 2 × 1.

How recursion works: the call stack

Each call gets its own frame on the call stack with its own n. The calls go down until the base case, then each one returns to the call that made it. This version prints every step:

def factorial(n, depth=0):
    indent = "    " * depth
    print(f"{indent}factorial({n}) called")
    if n <= 1:
        print(f"{indent}factorial({n}) returns 1   <- base case")
        return 1
    result = n * factorial(n - 1, depth + 1)
    print(f"{indent}factorial({n}) returns {n} * factorial({n - 1}) = {result}")
    return result

factorial(4)

Output:

factorial(4) called
    factorial(3) called
        factorial(2) called
            factorial(1) called
            factorial(1) returns 1   <- base case
        factorial(2) returns 2 * factorial(1) = 2
    factorial(3) returns 3 * factorial(2) = 6
factorial(4) returns 4 * factorial(3) = 24
Command Prompt output tracing a recursive Python factorial function: calls from factorial(4) down to the base case factorial(1) and the returns back up
Four frames go down; the results come back up in reverse order.

Base case, recursion limit and RecursionError

Python limits the depth of the call stack to protect itself. Forgetting the base case, or recursing on very large inputs, raises RecursionError:

import sys

print("recursion limit:", sys.getrecursionlimit())

def depth(n):
    return depth(n + 1)              # no base case

try:
    depth(0)
except RecursionError as e:
    print("RecursionError:", e)

def sum_to(n):
    return 0 if n == 0 else n + sum_to(n - 1)

print(sum_to(900))                   # fine
try:
    sum_to(5000)                     # deeper than the limit
except RecursionError as e:
    print("sum_to(5000) -> RecursionError:", e)

Output:

recursion limit: 1000
RecursionError: maximum recursion depth exceeded
405450
sum_to(5000) -> RecursionError: maximum recursion depth exceeded
Command Prompt output of Python sys.getrecursionlimit 1000 and RecursionError maximum recursion depth exceeded for a function without a base case and for sum_to(5000)
The default limit is 1000 frames.

sys.setrecursionlimit() can raise the limit, but each frame uses real stack memory and a very high limit can crash the interpreter. If your input can be deep, use a loop instead (see below).

Make recursion fast with memoization

The plain recursive Fibonacci function computes the same values again and again: fib(30) makes over a million calls. functools.lru_cache stores each result the first time, so every value is computed once:

from functools import lru_cache

calls = 0
def fib(n):
    global calls
    calls += 1
    return n if n < 2 else fib(n - 1) + fib(n - 2)

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

for n in (10, 20, 30):
    calls = 0
    value = fib(n)
    fib_memo.cache_clear()
    fib_memo(n)
    print(f"fib({n}) = {value:>6}   plain recursion: {calls:>9,} calls   memoized: {fib_memo.cache_info().misses} calls")

Output:

fib(10) =     55   plain recursion:       177 calls   memoized: 11 calls
fib(20) =   6765   plain recursion:    21,891 calls   memoized: 21 calls
fib(30) = 832040   plain recursion: 2,692,537 calls   memoized: 31 calls
Command Prompt output comparing plain recursive Fibonacci call counts with an lru_cache memoized version for n 10, 20 and 30
Plain recursion grows exponentially; with lru_cache it makes n + 1 calls.

The same comparison as a chart, including a plain for loop:

import time
from functools import lru_cache
import matplotlib.pyplot as plt

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

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

def fib_loop(n):
    a, b = 0, 1
    for _ in range(n):
        a, b = b, a + b
    return a

ns = list(range(5, 29))
def timed(func, n, clear=False):
    if clear:
        fib_memo.cache_clear()
    t = time.perf_counter()
    func(n)
    return (time.perf_counter() - t) * 1000

plain = [timed(fib, n) for n in ns]
memo = [timed(fib_memo, n, clear=True) for n in ns]
loop = [timed(fib_loop, n) for n in ns]

plt.figure(figsize=(8, 4.8))
plt.plot(ns, plain, "o-", label="plain recursion")
plt.plot(ns, memo, "s-", label="recursion + lru_cache")
plt.plot(ns, loop, "^-", label="for loop")
plt.yscale("log")
plt.xlabel("n")
plt.ylabel("time (ms, log scale)")
plt.title("Fibonacci: plain recursion vs memoization vs loop")
plt.legend()
plt.grid(True, which="both", alpha=0.3)
plt.tight_layout()
plt.show()
print(f"n=28: plain {plain[-1]:.1f} ms, memoized {memo[-1]:.3f} ms, loop {loop[-1]:.3f} ms")

Output:

n=28: plain 32.5 ms, memoized 0.004 ms, loop 0.001 ms
Matplotlib log-scale chart of Fibonacci timings for plain recursion, recursion with lru_cache and a for loop for n from 5 to 28
Plain recursion (top line) gets exponentially slower; memoized recursion and the loop stay flat.

More ways to write it in Fibonacci series in Python.

Recursion with nested data: lists inside lists

Recursion fits data that contains smaller copies of itself. A list that can contain lists at any depth is summed and flattened like this:

def total(items):
    """Sum numbers in a list that can contain lists, at any depth."""
    result = 0
    for item in items:
        if isinstance(item, list):
            result += total(item)       # recurse into the sub-list
        else:
            result += item
    return result

def flatten(items):
    for item in items:
        if isinstance(item, list):
            yield from flatten(item)
        else:
            yield item

data = [1, [2, 3], [4, [5, [6, 7]]], 8]
print(total(data))
print(list(flatten(data)))

Output:

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

Recursion with folders: print a directory tree

Folders contain folders, so walking them is naturally recursive:

from pathlib import Path

# build a small folder tree to walk
root = Path("project")
for f in ["README.md", "src/app.py", "src/utils/io.py", "src/utils/text.py", "tests/test_app.py"]:
    (root / f).parent.mkdir(parents=True, exist_ok=True)
    (root / f).write_text("x" * 100)

def show(folder, prefix=""):
    entries = sorted(folder.iterdir(), key=lambda p: (p.is_file(), p.name))
    for i, entry in enumerate(entries):
        last = i == len(entries) - 1
        print(prefix + ("└── " if last else "├── ") + entry.name)
        if entry.is_dir():
            show(entry, prefix + ("    " if last else "│   "))   # recurse into the sub-folder

def folder_size(folder):
    return sum(folder_size(p) if p.is_dir() else p.stat().st_size for p in folder.iterdir())

print(root.name)
show(root)
print("total size:", folder_size(root), "bytes")

Output:

project
├── src
│   ├── utils
│   │   ├── io.py
│   │   └── text.py
│   └── app.py
├── tests
│   └── test_app.py
└── README.md
total size: 500 bytes
Command Prompt output of a recursive Python function printing a project folder tree with sub-folders and the total folder size
A recursive tree printer and folder-size function.

For everyday file listing, Path.rglob() and os.walk() already do the recursion for you: see list files in a directory with Python.

Classic recursive algorithms

def binary_search(items, target, low=0, high=None):
    if high is None:
        high = len(items) - 1
    if low > high:
        return -1                                   # base case: not found
    mid = (low + high) // 2
    if items[mid] == target:
        return mid                                  # base case: found
    if items[mid] < target:
        return binary_search(items, target, mid + 1, high)
    return binary_search(items, target, low, mid - 1)

def permutations(s):
    if len(s) <= 1:
        return [s]
    return [ch + rest for i, ch in enumerate(s) for rest in permutations(s[:i] + s[i + 1:])]

def power(x, n):
    """x**n with O(log n) multiplications."""
    if n == 0:
        return 1
    half = power(x, n // 2)
    return half * half * (x if n % 2 else 1)

def reverse(s):
    return s if len(s) <= 1 else reverse(s[1:]) + s[0]

print(binary_search([3, 8, 15, 23, 42, 57, 91], 42))
print(permutations("abc"))
print(power(2, 30), 2 ** 30)
print(reverse("recursion"))

Output:

4
['abc', 'acb', 'bac', 'bca', 'cab', 'cba']
1073741824 1073741824
noisrucer

Step-by-step programs: binary search in Python (recursive and iterative), factorial of a number, reverse a string and Armstrong numbers.

Recursion vs iteration

Anything written recursively can be written with a loop. In Python the loop is usually faster and has no depth limit, and Python does not optimize tail calls, so even tail-recursive functions hit the limit:

import sys

def factorial_recursive(n):
    return 1 if n <= 1 else n * factorial_recursive(n - 1)

def factorial_loop(n):
    result = 1
    for i in range(2, n + 1):
        result *= i
    return result

print(factorial_recursive(20) == factorial_loop(20))
print(len(str(factorial_loop(1500))), "digits in 1500! (loop, no depth limit)")
try:
    factorial_recursive(1500)
except RecursionError:
    print("factorial_recursive(1500) -> RecursionError")

# Python does not optimize tail calls: this still uses one stack frame per call
def count_down_tail(n):
    return n if n == 0 else count_down_tail(n - 1)
try:
    count_down_tail(sys.getrecursionlimit() + 10)
except RecursionError:
    print("tail-recursive version -> RecursionError too")

Output:

True
4115 digits in 1500! (loop, no depth limit)
factorial_recursive(1500) -> RecursionError
tail-recursive version -> RecursionError too
RecursionIteration (loop)
Best forTrees, nested data, divide and conquer, backtrackingCounting, sequences, simple repetition
Depth limitAbout 1000 calls by defaultNone
Speed in PythonSlower (function call per step)Faster
ReadabilityShorter for naturally recursive problemsClearer for linear problems

Common recursion mistakes

def total_wrong(numbers):
    if not numbers:
        return 0
    numbers[0] + total_wrong(numbers[1:])    # forgot "return"

def total_right(numbers):
    if not numbers:
        return 0
    return numbers[0] + total_right(numbers[1:])

try:
    print(total_wrong([1, 2, 3]))
except TypeError as e:
    print("TypeError:", e)
print(total_right([1, 2, 3]))

def collect(n, found=[]):                   # mutable default keeps values between calls
    if n == 0:
        return found
    found.append(n)
    return collect(n - 1, found)

print(collect(3))
print(collect(2))                          # still contains 3, 2, 1 from the first call

Output:

TypeError: unsupported operand type(s) for +: 'int' and 'NoneType'
6
[3, 2, 1]
[3, 2, 1, 2, 1]
  • Missing return in the recursive case: the function returns None, and the caller’s arithmetic fails.
  • Mutable default arguments (found=[]) keep their contents between calls; use None and create the list inside.
  • No progress toward the base case (for example f(n) calling f(n)): always make the input smaller.
  • Repeated work: add @lru_cache when the same arguments are computed many times.

Practice recursion with these Python programs:

Frequently asked questions

What is recursion in Python?

A function that calls itself on a smaller input until it reaches a base case, then combines the results as the calls return.

What is a base case?

The condition where the function returns without calling itself, for example if n <= 1: return 1. Without it, recursion never stops.

What is the maximum recursion depth in Python?

1000 by default (sys.getrecursionlimit()). Going deeper raises RecursionError: maximum recursion depth exceeded.

How do I fix RecursionError?

Check that the base case is reached, rewrite deep recursion as a loop, or raise the limit carefully with sys.setrecursionlimit().

Is recursion slower than a loop in Python?

Usually yes: every call creates a new stack frame, and Python has no tail-call optimization. Memoization helps when calls repeat.

When should I use recursion?

For naturally recursive structures such as trees, nested lists, folders, divide-and-conquer algorithms and backtracking.