Learning Goals
3 minBy the end of this lesson you can:
- Identify the two parts of every recursive function — base case and recursive case.
- Write a small recursive function that counts down or sums.
- Trace a recursive call stack — what frame is active when.
- Avoid infinite recursion (and recognise
RecursionError).
Warm-Up · The Smallest Recursion
5 mindef countdown(n): if n <= 0: return # ← base case: stop print(n) countdown(n - 1) # ← recursive case: smaller problem countdown(5) # 5 # 4 # 3 # 2 # 1
Two branches. if n <= 0 stops. Otherwise print and call yourself with a smaller number. The recursion is guaranteed to end because n shrinks every step and the base case catches zero.
Every recursive function has two parts. Always write the base case first — that's the "exit door". Then write the recursive case that gets closer to it.
New Concept · Base + Recursive
14 minThe skeleton
def recursive_func(args):
if base_case_condition:
return base_value # the answer for the smallest input
# recursive case
smaller_args = make_smaller(args)
smaller_answer = recursive_func(smaller_args)
return combine(smaller_answer, args)Four ingredients: condition for base, return value at base, way to shrink the args, way to combine the recursive answer with the current step.
Sum 1..n — combine pattern
def sum_to(n): if n == 0: return 0 return n + sum_to(n - 1) print(sum_to(5)) # 5 + 4 + 3 + 2 + 1 + 0 = 15
Trace the calls:
sum_to(5) returns 5 + sum_to(4)
sum_to(4) returns 4 + sum_to(3)
sum_to(3) returns 3 + sum_to(2)
sum_to(2) returns 2 + sum_to(1)
sum_to(1) returns 1 + sum_to(0)
sum_to(0) returns 0 ← base case
unwinds to 1
unwinds to 3
unwinds to 6
unwinds to 10
final: 15Each call waits for the next to finish, then combines. The deepest call hits the base case; the answer bubbles back up.
Length of a list — list recursion
def length(lst): if lst == []: return 0 return 1 + length(lst[1:]) print(length(["a", "b", "c", "d"])) # 4
Base case: empty list. Recursive: drop the first item, add 1 to whatever the rest's length is. lst[1:] is the "everything but the head" slice.
Three rules for safe recursion
- Always write the base case first. Forgetting it = infinite recursion =
RecursionError: maximum recursion depth exceededafter about a thousand calls. - Make sure each recursive call moves toward the base case. Passing the same args = forever-loop. Use smaller numbers, shorter lists, simpler structures.
- Trust the recursion. Don't try to imagine every level in your head — that's impossible past 3 deep. Trust that the recursive call returns the right answer for the smaller problem, and focus on combining it with the current step.
Infinite recursion · the crash
def forever(n): return forever(n - 1) # ❌ no base case! # forever(5) # RecursionError: maximum recursion depth exceeded
Python protects you with a default depth limit (~1000). Hitting it means you forgot the base case or the recursive case isn't actually shrinking.
Reverse a string · build pattern
def reverse(s): if s == "": return "" return reverse(s[1:]) + s[0] print(reverse("hello")) # 'olleh'
Base: empty string reverses to empty. Recursive: reverse everything except the first char, then put the first char at the end.
The four shapes you'll see again and again
Numeric recursion f(n) calls f(n-1) until n hits 0 List recursion f(lst) calls f(lst[1:]) until lst is [] Divide & conquer f(big) calls f(left half) + f(right half) Tree recursion f(node) calls f(child1) and f(child2) — fractals!
Today we focus on numeric and list. Tomorrow brings factorial, fibonacci, powers. The art lessons (33-35) introduce tree recursion via turtle.
Worked Example · Five Recursive Helpers
12 minSave as recurse_basics.py:
# recurse_basics.py — five tiny recursive functions def countdown(n): """Print n, n-1, ..., 1.""" if n <= 0: return print(n) countdown(n - 1) def sum_to(n): """Sum 1..n.""" if n == 0: return 0 return n + sum_to(n - 1) def length(lst): """Length of a list. Recursive — no len().""" if lst == []: return 0 return 1 + length(lst[1:]) def max_in(lst): """Largest in a non-empty list. Recursive — no max().""" if len(lst) == 1: return lst[0] rest_max = max_in(lst[1:]) return lst[0] if lst[0] > rest_max else rest_max def reverse_str(s): """Reverse a string.""" if s == "": return "" return reverse_str(s[1:]) + s[0] # Demonstration print("--- countdown ---") countdown(5) print("\n--- sum_to(10) ---") print(sum_to(10)) # 55 print("\n--- length ---") print(length(["a", "b", "c", "d", "e"])) # 5 print("\n--- max_in ---") print(max_in([3, 1, 4, 1, 5, 9, 2, 6])) # 9 print("\n--- reverse_str ---") print(reverse_str("Aisyah")) # 'hayisA'
Read the diff
Five functions, all 4-5 lines each. Every one has a clear base case at the top and a recursive case below it. Trust-the-recursion in action: max_in says "the max of the list is either the first item OR the max of the rest". The function doesn't solve the whole problem — it solves one step and lets the recursion handle the rest.
Try It Yourself
13 minWrite a recursive countdown_by_2(n) that prints n, n-2, n-4, ... down to 0 or below.
Hint
def countdown_by_2(n): if n <= 0: return print(n) countdown_by_2(n - 2) countdown_by_2(10) # 10, 8, 6, 4, 2
Same shape as countdown — just shrinks by 2 instead of 1.
Write count_of(item, lst) recursively — no built-in .count().
Hint
def count_of(item, lst): if lst == []: return 0 rest = count_of(item, lst[1:]) return (1 if lst[0] == item else 0) + rest print(count_of("a", ["a", "b", "a", "c", "a"])) # 3
Base case: empty list = 0. Recursive: count is 1 if the head matches, plus the count in the rest.
Recursively sum the digits of a positive integer. digit_sum(1234) == 10.
Hint
def digit_sum(n): if n < 10: return n # single digit — base case return n % 10 + digit_sum(n // 10) # last digit + rest print(digit_sum(1234)) # 1+2+3+4 = 10 print(digit_sum(99999)) # 45
n % 10 is the last digit; n // 10 drops the last digit. Each step shrinks n; eventually n < 10 is the base case.
Mini-Challenge · Palindrome Check
8 minWrite a recursive is_palindrome(s) that returns True if s reads the same forwards and backwards. Ignore case and non-letter characters.
assert is_palindrome("racecar") assert is_palindrome("Was it a car or a cat I saw?") assert not is_palindrome("hello")
Show one possible solution
def is_palindrome(s): # Normalise first — strip non-letters and lower cleaned = "".join(ch.lower() for ch in s if ch.isalpha()) return _palindrome_recursive(cleaned) def _palindrome_recursive(s): if len(s) <= 1: return True # base: empty or single char is a palindrome if s[0] != s[-1]: return False # base: ends don't match return _palindrome_recursive(s[1:-1]) # recurse on middle print(is_palindrome("racecar")) # True print(is_palindrome("Was it a car or a cat I saw?")) # True print(is_palindrome("hello")) # False
Non-negotiables: a normalise step (so "Was it a car..." works), and a recursive function that checks the ends and recurses on the middle. Two base cases — empty and mismatch — plus the recursive trim.
Recap
3 minEvery recursive function has two parts: a base case that returns directly, and a recursive case that calls itself with a smaller problem. Always write the base case first. Make sure each recursive call moves toward it. Trust that the recursive call returns the right answer for the smaller problem — your job is just to handle the current step. Forgetting the base case raises RecursionError after ~1000 nested calls.
Vocabulary Card
- recursion
- A function that calls itself, usually with a smaller argument.
- base case
- The condition under which the function returns directly, without recursing.
- recursive case
- The path that calls the function again with a shrunken problem.
- RecursionError
- The crash when nested calls exceed Python's depth limit (~1000).
- call stack
- The stack of pending function calls. Each recursive call adds a frame; each return pops one.
Homework
4 minBuild recurse_kit.py with four recursive functions. No for/while loops anywhere.
repeat_string(s, n)— returnsrepeatedntimes. (Yes, without*.)flatten_one_level(lst)— given a list of lists, return a single concatenated list (one level only).last(lst)— return the last item of a non-empty list.contains(item, lst)— True ifitemappears inlst; noinoperator on the list.
Sample · recurse_kit.py
def repeat_string(s, n): if n <= 0: return "" return s + repeat_string(s, n - 1) def flatten_one_level(lst): if lst == []: return [] return lst[0] + flatten_one_level(lst[1:]) def last(lst): if len(lst) == 1: return lst[0] return last(lst[1:]) def contains(item, lst): if lst == []: return False if lst[0] == item: return True return contains(item, lst[1:]) print(repeat_string("ab", 3)) # 'ababab' print(flatten_one_level([[1, 2], [3], [4, 5, 6]])) # [1, 2, 3, 4, 5, 6] print(last([10, 20, 30])) # 30 print(contains(3, [1, 2, 3, 4])) # True print(contains(99, [1, 2, 3, 4])) # False
Non-negotiables: four recursive functions, no loops. Each has a clear base case and shrinking step. flatten_one_level uses + on lists — same as concat.