← Course Home
Module 8

Troubleshooting

Reading tracebacks, naming what went wrong, and fixing bugs that don't even throw an error — the highest-value skill in this whole course.

Concept

What an Exception Is

An exception is an object Python creates the moment something goes wrong — it stops your program right there and prints a report explaining what happened, unless you catch it.

nums = [1, 2, 3]
print(nums[5])
# IndexError: list index out of range
Lab 1

Run the example above yourself. Confirm the exact error.

IndexError: list index out of range
Lab 2

Try {"a": 1}["b"]. What exception, and what does it say?

KeyError: 'b'
Lab 3

Try None + "text". What exception?

TypeError
Lab 4

Try int("abc"). What exception?

ValueError
Lab 5

Try printing a variable name you never created. What exception?

NameError
Now go practice

Open problems/01_what_is_an_exception.py and do all 5 problems locally.

Concept

Reading a Traceback Bottom-Up

A traceback is the call chain that led to a crash. Read it from the bottom — that line tells you what actually broke. The lines above it, going up, show the chain of calls that got you there.

def fetch_active_users_broken(users):
    active = []
    for u in users:
        if u["status"] == "ACTIVE":
            active.append(u)
    # forgot return!

def count_by_dept(users):
    counts = {}
    for u in users:
        counts[u["profile"]["department"]] = 1
    return counts

active = fetch_active_users_broken(USERS)
result = count_by_dept(active)
# TypeError: 'NoneType' object is not iterable

You met this exact bug in Module 7. Here's its name: the bottom line (TypeError) is where it crashed. The real cause — the missing return — is two functions away.

Lab 1

Run the example above. Confirm the exact error message.

TypeError: 'NoneType' object is not iterable
Lab 2

Which line of the traceback tells you the exception type — the top or the bottom?

The bottom
Lab 3

In the example, which function call happens first — fetch_active_users_broken or count_by_dept?

fetch_active_users_broken
Lab 4

Does the traceback show you WHERE the code broke, or WHY it's wrong? (one word: where/why)

Where
Lab 5

Write your own two-function chain with a deliberate crash in the second function. Run it, read your own traceback bottom-up.

Now go practice

Open problems/02_reading_tracebacks.py and do all 5 problems locally.

Concept

Crash Site vs. Cause Site

The crash site is the line the traceback points at — where Python finally gave up. The cause site is where the real mistake was made. They're often not the same line, and sometimes not even the same function.

# Cause site: this function forgot to return
def fetch_active_users_broken(users):
    active = []
    for u in users:
        if u["status"] == "ACTIVE":
            active.append(u)
    # no return -- THIS is the cause

# Crash site: the error actually shows up here
result = count_by_dept(fetch_active_users_broken(USERS))
# TypeError -- but the real mistake was one function earlier
Lab 1

In the example above, name the cause site (which function, which missing line).

fetch_active_users_broken, missing return active
Lab 2

Name the crash site.

Inside count_by_dept, the for u in users: line
Lab 3

Could the crash site and cause site ever be the exact same line? Give a one-line example where they are.

e.g. print(1/0) — cause and crash are the same line
Lab 4

Why is "fix the crash site" often the wrong move? (one sentence, as a comment)

it treats the symptom, not the actual mistake
Lab 5

Take your two-function chain from the last sub-skill's Lab 5. Identify its cause site and crash site out loud (as comments above each line).

Now go practice

Open problems/03_crash_vs_cause.py and do all 5 problems locally.

Concept

A Real Multi-Function Crash

Same idea, applied to the real fixture — a helper function used inside a loop, crashing partway through.

def get_department(user):
    return user["profile"]["department"]

def show_all_departments(users):
    for u in users:
        print(get_department(u))

show_all_departments(USERS)
Predict first

Run the code above. How many departments print before it crashes, and what's the exception?

Check
7 departments print, then crashesKeyError: 'department'

Cause site: the fixture — Layla (USERS[7]) has no department key. Crash site: inside get_department, called from inside show_all_departments's loop — two functions removed from where the "bad" data actually lives.

Lab 1

Confirm it yourself — run it, count the printed lines before the crash.

7
Lab 2

Name the cause site in one sentence.

Lab 3

Name the crash site in one sentence.

Lab 4

Write a guarded version of get_department using .get() with a default — confirm it runs all 12 without crashing.

Now go practice

Open problems/04_multifunction_crash.py and do all 4 problems locally.

Concept

The Dangerous Ones: Wrong Answer, No Error

Every bug so far has crashed — loud, obvious, a traceback to read. There's a worse category: a script that runs perfectly, prints something, and the something is wrong. No error. Nothing tells you to look.

def most_logins(counts):
    top = None
    for name in counts:
        top = name   # never actually compares -- just keeps overwriting
    return top

print(most_logins({"a": 5, "b": 10, "c": 3}))
# "c" -- but "b" has the highest count (10). This is silently wrong.
Lab 1

Run the example. Confirm it prints "c", not the true max.

c
Lab 2

Manually check: which key actually has the highest value in {"a": 5, "b": 10, "c": 3}?

b
Lab 3

Why is this bug more dangerous than a crash? One sentence.

no error means nobody notices to check
Lab 4

Fix most_logins so it actually compares and tracks the true max. Test it on the same dict.

b
Lab 5 · Checkpoint

Write a two-function chain (fetch + analyze) where the analyze step has this exact "silently overwrite instead of compare" bug on real USERS data (e.g. finding the department with the most users). Run it, notice the wrong answer.

Combines sub-skills 1–4 with this new bug category.
Now go practice

Open problems/05_silent_bugs.py — 5 problems, #5 is a checkpoint combining sub-skills 1–4.

Concept

try / except

try runs code that might crash. If it does, except catches the exception instead of letting the whole program die — you decide what happens next.

try:
    print(1 / 0)
except:
    print("something went wrong")
Lab 1

Run the example above. Confirm it prints the fallback message, not a crash.

something went wrong
Lab 2

Wrap int("abc") in a try/except that prints "couldn't convert".

couldn't convert
Lab 3

Wrap {"a": 1}["b"] the same way, printing "key not found".

key not found
Lab 4

Put code that does NOT crash inside a try. Confirm the except block never runs.

only the try's own output prints
Lab 5

Wrap the fixture's USERS[7]["profile"]["department"] access in try/except, printing "Unknown" on failure.

Unknown
Now go practice

Open problems/06_try_except.py and do all 5 problems locally.

Concept

Catching Specific Exception Types

Instead of catching everything blindly, name the exact exception you expect — except KeyError:, except TypeError:. That way an unrelated bug doesn't get silently swallowed too.

try:
    print({"a": 1}["b"])
except KeyError:
    print("that key doesn't exist")
Lab 1

Run the example. Confirm the specific message.

that key doesn't exist
Lab 2

Catch int("xyz") with except ValueError:, printing "not a number".

not a number
Lab 3

Catch [1, 2][5] with except IndexError:, printing "out of range".

out of range
Lab 4

Write try/except KeyError: around code that actually raises TypeError — predict, then run. What happens?

Still crashes — the wrong exception type isn't caught
Lab 5

Catch None + "x" with except TypeError:, printing "can't combine those".

can't combine those
Now go practice

Open problems/07_specific_exceptions.py and do all 5 problems locally.

Concept

raise

raise deliberately triggers an exception — useful when your own code detects bad data and wants to stop loudly, on purpose, instead of continuing with garbage.

def set_age(age):
    if age < 0:
        raise ValueError("age can't be negative")
    print(f"age set to {age}")

set_age(-5)   # ValueError: age can't be negative
Lab 1

Run the example. Confirm the exact error.

ValueError: age can't be negative
Lab 2

Call set_age(30) instead — confirm no crash.

age set to 30
Lab 3

Write a function set_status(status) that raises ValueError if status isn't "ACTIVE", "SUSPENDED", or "DEPROVISIONED". Test it with an invalid value.

Crashes — your custom message
Lab 4

Wrap a call to that function in try/except ValueError:, catching your own raised error.

caught, prints your handler's message
Lab 5

Write a function that raises KeyError on purpose (not from a real dict access — just raise KeyError("custom message")).

CrashesKeyError: 'custom message'
Now go practice

Open problems/08_raise.py and do all 5 problems locally.

Concept

Fix the Bug, or Catch It?

try/except isn't always the right tool. If a bug is your mistake (like the missing-return bug), the fix is fixing your code — not wrapping it in a try/except that hides the real problem. Catch exceptions that come from things outside your control: missing/messy data, not your own logic errors.

Lab 1

Is a missing return statement something to catch with try/except, or something to fix directly? (one word)

Fix
Lab 2

Is a user record missing a department key (real messy data) something to catch, or fix in your code? (one word)

Catch (or guard with .get())
Lab 3

Wrap a call to a function with a real logic bug (like the accumulator KeyError from Module 7) in a blind try/except: pass. What's wrong with doing this instead of fixing the bug?

it hides the bug instead of fixing it — silently wrong data continues
Lab 4

Write one sentence (as a comment) explaining when try/except is the RIGHT tool.

Now go practice

Open problems/09_judgment_call.py and do all 4 problems locally.

Concept

Drill 1: The Three-Bug Script

A real script, several bugs, not told how many. Diagnose before you fix.

def get_active(users):
    active = []
    for u in users:
        if u["status"] == "ACTIVE":
            active.append(u)
    # bug?

def count_by_dept(users):
    counts = {}
    for u in users:
        counts[u["profile"]["department"]] = counts[u["profile"]["department"]] + 1
    return counts

active = get_active(USERS)
result = count_by_dept(active)
Predict first

How many bugs are in this script, and what crashes first?

Check
CrashesTypeError: 'NoneType' object is not iterable

There are actually three bugs: get_active is missing its return, count_by_dept uses counts[dept] + 1 (would KeyError on first occurrence), and it also uses direct ["department"] access (would crash on Layla). But only the FIRST one — the missing return — actually triggers, because the script never gets far enough to hit the other two. Fixing bugs one at a time often reveals the next one underneath.

Lab 1

Fix the missing return. Run again — what crashes now?

CrashesKeyError, first department it sees
Lab 2

Fix the accumulator to use .get(dept, 0) + 1. Run again — what crashes now?

CrashesKeyError: 'department' on Layla
Lab 3

Fix the direct key access to use .get("department", "Unknown"). Run the whole script — confirm it finishes clean.

runs to completion, no crash
Lab 4

Print the final counts dict from your fully-fixed version.

{'IT': 2, 'Security': 2, 'Engineering': 3, 'Unknown': 1}
Lab 5 · Checkpoint

Explain in three sentences what was wrong and why fixing it one bug at a time was necessary here — three separate crash sites, one at a time.

Combines sub-skills 1–9's diagnostic vocabulary.
Now go practice

Open problems/10_drill1_three_bugs.py — 5 problems, #5 is a checkpoint combining sub-skills 1–9.

Concept

Drill 2: The Crash Far From Its Cause

A subtler bug — appending the wrong thing to a list, not a missing return or a bad key.

def collect_group_names(user):
    names = []
    for g in user["groups"]:
        names.append(g)   # bug: appends the whole group dict, not g["name"]
    return names

def show_shouted(names):
    for n in names:
        print(n.upper())

names = collect_group_names(USERS[0])
show_shouted(names)
Predict first

What happens when this runs?

Check
CrashesAttributeError: 'dict' object has no attribute 'upper'

Cause site: collect_group_names appended the whole group dict instead of group["name"]. Crash site: a completely different function, show_shouted, which assumed it was getting strings. The crash message doesn't mention "wrong type appended" anywhere — you have to trace it back yourself.

Lab 1

Fix collect_group_names to append g["name"] instead. Run the whole thing again.

no crash, names print uppercased
Lab 2

Print the fixed result for USERS[0] (Julio).

ENGINEERING then VPN-USERS
Lab 3

Name the cause site and crash site in one sentence each.

Lab 4

Why didn't collect_group_names itself crash? (it ran fine, appending dicts just fine)

appending a dict to a list is legal — the type mismatch only matters once .upper() is called
Now go practice

Open problems/11_drill2_wrong_type.py and do all 4 problems locally.

Concept

Drill 3: Mutating a List While Iterating

One of the dangerous ones — no crash. Removing items from a list while looping over it skips elements, because positions shift out from under the loop.

def remove_inactive(users):
    for u in users:
        if u["status"] != "ACTIVE":
            users.remove(u)
    return users

test = [
    {"id": "a", "status": "ACTIVE"},
    {"id": "b", "status": "SUSPENDED"},
    {"id": "c", "status": "SUSPENDED"},
    {"id": "d", "status": "ACTIVE"},
]
print(remove_inactive(test))
Predict first

Both "b" and "c" are SUSPENDED. Does the result remove both?

Check
No — silently wrong: "c" stays in the list even though it should have been removed

No error anywhere. When "b" gets removed, everything after it shifts down one position — the loop's next step then skips over "c" entirely. This is why you never mutate a list you're currently looping over.

Lab 1

Run the example. Confirm "c" really does survive.

→ result includes "c"
Lab 2

Fix it: build a NEW list of only the ACTIVE users instead of removing from the original. Test it on the same data.

correctly keeps only "a" and "d"
Lab 3

Run your fixed version on the full USERS fixture. Print the count.

8
Lab 4

In one sentence: why does removing from a list while looping over it cause elements to get skipped?

positions shift after each removal, so the loop's index moves past an element
Now go practice

Open problems/12_drill3_mutate_while_iterating.py and do all 4 problems locally.

Concept

Drill 4: Silent Wrong Answer, Then a New Crash

The same "never tracks a max" bug from earlier, now with a second problem: what happens when the input is empty.

def most_logins(counts):
    for name in counts:
        top = name
    return top

print(most_logins({"a": 5, "b": 10, "c": 3}))
print(most_logins({}))
Predict first

The first call is silently wrong (you already know this). What happens on the SECOND call, with an empty dict?

Check
CrashesUnboundLocalError: cannot access local variable 'top' where it is not associated with a value

A genuinely new exception type. Since counts is empty, the loop body never runs even once — top never gets created at all, so return top fails. The bug that was silent for real data becomes a hard crash for edge-case data.

Lab 1

Run the example. Confirm both outcomes yourself.

wrong answer, then a crash
Lab 2

Fix most_logins so it actually tracks the true max AND handles an empty dict safely (return None for empty). Test both cases.

b then None
Lab 3

Run your fixed version on real login counts built from LOGIN_COUNTS. Print the top email.

mfoster@example.com (203, the highest)
Lab 4

What's the general lesson: why did testing only with real, non-empty data hide this bug for so long?

edge cases (empty input) reveal bugs that normal data never triggers
Now go practice

Open problems/13_drill4_silent_and_crash.py and do all 4 problems locally.

Concept

Writing Defensive Multi-Function Scripts

Combine everything: guard patterns (.get(), in checks from earlier modules) for data you expect to be messy, and try/except/raise for genuinely unexpected failures.

Lab 1

Write a function that safely gets a user's department using .get() — no exception handling needed, since .get() already prevents the crash.

Lab 2

Write a function that wraps a genuinely risky operation (like int() on unpredictable text) in try/except ValueError, returning None on failure.

Lab 3

Combine both patterns in one small chain: fetch users (guard missing department), then convert a login count string safely (try/except), then report.

Lab 4

Run your combined chain over the real fixture, confirming no crash across all 12 records.

Now go practice

Open problems/14_defensive_scripts.py and do all 4 problems locally.

Concept

Full-Fixture Diagnostic Report

Everything from this module, over the whole fixture: crashes, crash-far-from-cause, silent skips, silent wrong answers, and defensive handling.

Lab 1

Loop all USERS, safely printing each department with a guard — no crash on Layla.

12 lines
Lab 2

Build a new list (not mutate-in-place) of only SUSPENDED users. Print the count.

2
Lab 3

Find the department with the most ACTIVE users, using a properly-fixed max-tracking function (not the silent-overwrite version).

Engineering (3)
Lab 4

Wrap a risky conversion (e.g. converting a login count to a different type) in try/except, handling failure gracefully.

Lab 5 · Final checkpoint

One script combining: a guarded department report, a properly-built (not mutated) filtered list, a fixed max-finder, and at least one try/except — over the full 12-record fixture, zero crashes, zero silent wrong answers.

This is the big one — take your time.
Now go practice

Open problems/15_full_diagnostic_report.py — 5 problems, #5 is the final checkpoint for all of Module 8.

Complete

Module 8 done

what an exception is → reading tracebacks → crash site vs cause site → the silent-bug category → try/except → catching specific types → raise → fix vs catch → four real drills → defensive scripts.

Run python check.py in problems/ to grade everything.