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
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
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
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
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
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
| Recursion | Iteration (loop) | |
|---|---|---|
| Best for | Trees, nested data, divide and conquer, backtracking | Counting, sequences, simple repetition |
| Depth limit | About 1000 calls by default | None |
| Speed in Python | Slower (function call per step) | Faster |
| Readability | Shorter for naturally recursive problems | Clearer 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
returnin the recursive case: the function returnsNone, and the caller’s arithmetic fails. - Mutable default arguments (
found=[]) keep their contents between calls; useNoneand create the list inside. - No progress toward the base case (for example
f(n)callingf(n)): always make the input smaller. - Repeated work: add
@lru_cachewhen the same arguments are computed many times.
Practice recursion with these Python programs:
- Fibonacci series in Python (with and without recursion)
- Binary search in Python
- Factorial of a number in Python
- Sum of digits of a number
- Armstrong number in Python
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.
Bijay Kumar is a 13-time Microsoft MVP with more than 18 years in software development, and the founder of Python Guides and TSinfo Technologies. He started out building .NET and SharePoint solutions at HP, TCS and KPIT before moving into Python, machine learning and AI, and he also builds web apps with TypeScript and React. He writes the tutorials here himself, and every example is run before publishing so you see the real output. More about Bijay · Microsoft MVP profile · LinkedIn