PYTH-201 · Lesson 6 of 12 · 24 min

Recursion

▶ Video coming soon A video version of this lesson is on the way.

Ridgeline's holiday range includes gift boxes, and some gift boxes contain smaller boxes: the Explorer box holds two bags of coffee and a Brewing Kit, and the Brewing Kit holds a mug, a filter set and a sample bag. To price the Explorer box, you price everything inside it, and to price the Brewing Kit inside it, you price everything inside that. The problem contains smaller copies of itself. Recursion is the technique of solving a problem like this by having a function call itself on the smaller pieces.

Recursion is the natural tool for nested data such as gift boxes, folders and the trees of lesson 9, and for divide-and-conquer algorithms such as the binary search of lesson 7 and the merge sort of lesson 8. This lesson explains how recursive functions are built, what happens on the call stack while they run, how to keep them from running forever or running out of stack, how memoization makes some of them dramatically faster, and when an ordinary loop is the better choice.

Base case and recursive case

A recursive function has two parts. The base case is a version of the problem small enough to answer directly, without another call. The recursive case breaks the problem into a smaller version of the same problem, calls the function on it, and uses the result. Every recursive call must move closer to a base case; otherwise the calls never stop.

Counting down to a roast is the simplest example. To count down from n, print n and then count down from n - 1; the base case is reaching zero. A slightly more useful example adds up a list: the total of an empty list is 0 (the base case), and the total of any other list is its first item plus the total of the rest (the recursive case).

Two recursive functions, each with a base case and a smaller recursive call
def countdown(n):
    if n == 0:                       # base case
        print("Drop the beans!")
        return
    print(n)
    countdown(n - 1)                 # recursive case: a smaller problem

def total(prices):
    if not prices:                   # base case: an empty list totals 0
        return 0
    return prices[0] + total(prices[1:])

countdown(3)
print(total([18.5, 22.0, 19.75]))
Output
3
2
1
Drop the beans!
60.25

The total function is a teaching example, not good practice: the slice prices[1:] copies the list on every call, making it O(n²), and sum(prices) is simpler and faster. Recursion earns its place on problems whose shape is recursive, like gift boxes inside gift boxes, rather than on flat lists.

The call stack

Each time a function is called, Python creates a frame holding that call's local variables and the place to return to, and pushes it onto the call stack, the stack you met in lesson 3. When the function returns, its frame is popped and the caller carries on. During recursion, the stack holds one frame per unfinished call: factorial(4) waits for factorial(3), which waits for factorial(2), and so on down to the base case. Then the answers flow back up as the frames pop.

Tracing the calls: each level waits for the one below it
def factorial(n, depth=0):
    pad = "  " * depth
    print(f"{pad}factorial({n}) called")
    if n <= 1:
        print(f"{pad}base case returns 1")
        return 1
    result = n * factorial(n - 1, depth + 1)
    print(f"{pad}factorial({n}) returns {result}")
    return result

factorial(4)
Output
factorial(4) called
  factorial(3) called
    factorial(2) called
      factorial(1) called
      base case returns 1
    factorial(2) returns 2
  factorial(3) returns 6
factorial(4) returns 24
Four snapshots of the call stack while computing factorial(4). The stack grows from one frame, factorial(4), to four frames with factorial(1) on top. Then factorial(1) returns 1 and is popped, factorial(2) returns 2, factorial(3) returns 6, and finally factorial(4) returns 24, with each return value drawn as an arrow passing down to the frame below.
The call stack grows to the base case, then each return hands its answer to the caller

Because every unfinished call holds a frame, deep recursion uses memory, and Python sets a recursion limit, normally 1,000 frames, to stop a runaway recursion from crashing the interpreter. Exceeding it raises RecursionError. Missing or unreachable base cases are the usual cause; a correct recursion that simply goes very deep, such as walking a linked list of 10,000 nodes, is better written as a loop.

A recursion without a base case hits the recursion limit
import sys

def no_base_case(n):
    return no_base_case(n + 1)       # never stops

print("limit:", sys.getrecursionlimit())
try:
    no_base_case(0)
except RecursionError as e:
    print("RecursionError:", e)
Output
limit: 1000
RecursionError: maximum recursion depth exceeded

Recursion on nested data

Recursion is at its best with data that nests to any depth, because a loop alone cannot know how many levels to descend. Ridgeline's gift boxes are stored as dictionaries: a simple product has a price, and a box has a list of contents, which may themselves be boxes. The price of a box is the sum of the prices of its contents, and the function that computes it simply calls itself on each item inside.

Pricing and printing nested gift boxes recursively
explorer = {
    "name": "Explorer box",
    "contents": [
        {"name": "Sunrise Blend", "price": 18.50},
        {"name": "Ethiopia Guji", "price": 22.00},
        {"name": "Brewing Kit", "contents": [
            {"name": "Ridgeline mug", "price": 14.00},
            {"name": "Filter set", "price": 9.50},
            {"name": "Sample bag", "price": 6.00},
        ]},
    ],
}

def price(item):
    if "price" in item:                       # base case: a single product
        return item["price"]
    return sum(price(part) for part in item["contents"])

def show(item, depth=0):
    print("  " * depth + f"{item['name']}: {price(item):.2f}")
    for part in item.get("contents", []):
        show(part, depth + 1)

show(explorer)
Output
Explorer box: 70.00
  Sunrise Blend: 18.50
  Ethiopia Guji: 22.00
  Brewing Kit: 29.50
    Ridgeline mug: 14.00
    Filter set: 9.50
    Sample bag: 6.00

Look how little code is needed: the function handles one level and trusts the recursive call to handle everything below. That trust, sometimes called the recursive leap of faith, is the key mental habit. To check a recursive function, confirm that the base case is right, that each call works on something smaller, and that the result is correctly built from the answers to the smaller calls. The same pattern walks folders on disk, JSON documents and the trees of lesson 9.

Try it yourself

Count the products in a gift box

Gift boxes are dictionaries as in the lesson: a product has a "price" key, and a box has a "contents" list of items, which may themselves be boxes.

Write a recursive function count_products(item) that returns how many individual products (items with a price) the item contains. A single product counts as 1, and an empty box counts as 0.

def count_products(item):
    return len(item.get('contents', []))

print(count_products(explorer))

Divide and conquer

Many of the fastest algorithms are recursive because they divide a problem into halves, solve each half recursively and combine the results. This strategy is called divide and conquer. Finding the highest sale in a list can be done that way: the maximum of a list is the larger of the maximum of its left half and the maximum of its right half, and a list of one item is its own maximum. Each level of recursion halves the problem, so there are about log₂ n levels.

Divide and conquer: the biggest sale from the biggest of each half
def biggest(sales, lo=0, hi=None):
    if hi is None:
        hi = len(sales) - 1
    if lo == hi:                              # one item: it is the biggest
        return sales[lo]
    mid = (lo + hi) // 2
    return max(biggest(sales, lo, mid), biggest(sales, mid + 1, hi))

day = [18.5, 44.0, 5.25, 61.75, 22.0, 9.5, 37.0]
print(biggest(day), max(day))
Output
61.75 61.75

Passing lo and hi indexes instead of slicing avoids copying the list on every call, a technique you will use again in binary search and merge sort. For a simple maximum, the built-in max is still the right choice; the value of the example is the shape of the solution, which lessons 7 and 8 turn into genuinely faster algorithms.

Memoization

Some recursive functions solve the same smaller problems over and over. Kai packs roasted coffee into bags of 1, 2 or 5 kilograms for wholesale customers and wants to know how many different sequences of bags make up a given order weight. The number of ways to make n kilograms is the number of ways to make n - 1 (then add a 1 kg bag), plus the ways to make n - 2, plus the ways to make n - 5. Written directly, that recursion recomputes the same values exponentially many times.

Memoization stores the answer to each subproblem the first time it is computed and looks it up afterward. The functools.cache decorator, a simpler form of the lru_cache you met in PYTH-102, does it automatically by keeping a dictionary from arguments to results. The listing counts how many calls each version makes.

Memoization turns an exponential recursion into a linear one
import functools

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

@functools.cache
def ways(n):
    if n < 0:
        return 0
    if n == 0:
        return 1
    return ways(n - 1) + ways(n - 2) + ways(n - 5)

print(ways_slow(20), "using", calls, "calls")
print(ways(20), "using", ways.cache_info().misses, "distinct computations")
print(ways(200))
Output
26547 using 112981 calls
26547 using 25 distinct computations
13487966597824560643006952047078537117138130993

The slow version makes tens of thousands of calls for n = 20 and would need longer than the age of the universe for n = 200; the memoized version computes each value once, so n = 200 takes about 200 steps. This idea of solving each overlapping subproblem once is called dynamic programming, and it can also be written bottom-up with a loop that fills a list from the smallest case upward, avoiding deep recursion altogether.

Try it yourself

Ways to make a gift card amount

A customer can pay for a gift card with vouchers worth 5, 10 or 25 dollars, used in any order. Write ways(amount) returning how many ordered sequences of vouchers add up to exactly amount (a whole number of dollars).

ways(0) is 1 (the empty sequence), and negative amounts have 0 ways. For example ways(10) is 2 (5+5, or 10). The starter is already memoized with functools.cache, so ways(500) is fast, but its base case is wrong: fix it so negative amounts give 0.

import functools

@functools.cache
def ways(amount):
    if amount <= 0:
        return 1
    return ways(amount - 5) + ways(amount - 10) + ways(amount - 25)

print(ways(10))

Recursion or a loop?

Anything written recursively can be written with a loop and, if needed, an explicit stack, and anything written with a loop can be written recursively. Choose by clarity and by depth. Recursion is clearer for nested structures and divide-and-conquer algorithms, where the depth is modest: a balanced tree of a million items is only about 20 levels deep. A loop is better for long linear processes, such as walking a linked list or counting down from a large number, where recursion would use one frame per step and hit the recursion limit. Unlike some languages, Python does not optimize tail calls, so even a recursive call in the last position of a function uses a new frame.

The same gift-box calculation with a loop and an explicit stack
def price_iterative(item):
    """The gift-box price with an explicit stack instead of recursion."""
    total, stack = 0.0, [item]
    while stack:
        current = stack.pop()
        if "price" in current:
            total += current["price"]
        else:
            stack.extend(current["contents"])
    return total

box = {"contents": [{"price": 18.5}, {"contents": [{"price": 14.0}, {"price": 9.5}]}]}
print(price_iterative(box))
Output
42.0

Worked problem

Designing a recursive folder size

Sam stores the shop's product photos in nested folders, represented as dictionaries in which each key is a file or folder name, a file's value is its size in kilobytes, and a folder's value is another dictionary. Design a function that returns the total size of a folder.

Show solution
  1. Identify the smaller problem: a folder's size is the sum of the sizes of the things directly inside it, and each subfolder is a smaller folder.
  2. Base case: a file, whose value is a number, contributes its own size.
  3. Recursive case: a subfolder, whose value is a dictionary, contributes folder_size(subfolder).
  4. Combine: add the contributions of every entry in the folder.
  5. Every call works on a folder one level deeper, so the recursion ends at the files; the depth equals the deepest folder nesting, which is small.

Answer: def folder_size(folder): return sum(v if isinstance(v, (int, float)) else folder_size(v) for v in folder.values())

Try it yourself

Total folder size

Folders are dictionaries mapping names to either a file size (an int, in kilobytes) or another folder (a dictionary). Write a recursive function folder_size(folder) that returns the total size of all files inside, at every level.

An empty folder has size 0.

def folder_size(folder):
    return sum(v for v in folder.values() if isinstance(v, int))

photos = {'logo.png': 120, 'coffees': {'rc-101.jpg': 850, 'rc-102.jpg': 910}}
print(folder_size(photos))

Common mistakes

  • MistakeWriting a recursive function with no base case, or one that is never reached.

    InsteadStart with the base case and check that every call moves toward it.

  • MistakeSlicing the list on every recursive call, as in f(items[1:]).

    InsteadPass start and end indexes instead of copying.

  • MistakeUsing recursion for long linear tasks such as walking 10,000 nodes.

    InsteadUse a loop; Python's recursion limit is about 1,000 frames.

  • MistakeRecomputing overlapping subproblems, as in naive Fibonacci.

    InsteadMemoize with functools.cache, or build the answers bottom-up in a loop.

  • MistakeRaising sys.setrecursionlimit to fix a RecursionError.

    InsteadFind the missing base case, or rewrite the deep recursion as a loop.

  • MistakeForgetting to return the result of the recursive call.

    InsteadWrite return f(smaller), not just f(smaller).

Summary and key terms

  • A recursive function solves a problem by calling itself on smaller versions of it.
  • Every recursion needs a base case answered directly and a recursive case that moves toward it.
  • Each call adds a frame to the call stack; Python limits the depth to about 1,000 frames.
  • Recursion suits nested data such as gift boxes, folders and trees, where the depth is not known in advance.
  • Divide and conquer splits a problem into halves, solves them recursively and combines the results.
  • Memoization with functools.cache stores answers to repeated subproblems, the basis of dynamic programming.
  • Choose recursion for clarity on recursive shapes, and loops for long linear processes.
recursion
Solving a problem with a function that calls itself on smaller instances.
base case
A case answered directly, which stops the recursion.
recursive case
A case solved by calling the function on a smaller problem.
call stack
The stack of frames for all function calls that have not yet returned.
frame
The record of one function call: its local variables and return point.
RecursionError
The exception raised when recursion exceeds Python's depth limit.
divide and conquer
Splitting a problem into parts, solving them recursively and combining the results.
memoization
Caching the results of function calls to avoid recomputing them.
dynamic programming
Solving problems with overlapping subproblems by solving each subproblem once.
functools.cache
A decorator that memoizes a function with an unlimited cache.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. What are the two essential parts of every recursive function?

    Show answer

    A base case and a recursive case that moves toward it.

  2. How many frames are on the stack at the deepest point of factorial(5)?

    Show answer

    Five: factorial(5) down to factorial(1).

  3. Why is total(prices[1:]) inefficient for long lists?

    Show answer

    Each slice copies the rest of the list, so the total cost is O(n²).

  4. What happens if a recursive function never reaches its base case?

    Show answer

    It keeps calling itself until Python raises RecursionError.

  5. Why does @functools.cache speed up the ways function so much?

    Show answer

    Each amount is computed once and then looked up, instead of being recomputed exponentially often.

  6. Would you walk a 50,000-node linked list recursively in Python?

    Show answer

    No: it would exceed the recursion limit; use a loop.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz