Dinesh/ Blog
python

Errors, Exceptions & Debugging with pdb

Read tracebacks, handle and chain exceptions deliberately, and inspect a reproducible failure with pdb while preserving cleanup and error contracts.

27 min readChecking device speech…
Lesson preparation & details

Level: beginner

Python and software engineering · Lesson 11 of 15 ↗
Loading this browser’s progress…

By the end, you should be able to

  • Explicit raise-from sets __cause__; implicit chaining retains __context__

Sources checked: . A source check does not change the publication or update date.

Editorial review: · What review means

In this article · 23 sections

Review boundary and corrected contracts

Explicit raise-from sets cause; implicit chaining retains context. Context managers close resources but the original open_and_lock did not lock anything; renamed it and made its demo in-memory. The standalone debug_me.py exercise is designed for a real pdb session; execution evidence is recorded separately from ordinary example tests. Logging exception information is a policy choice, not always appropriate for sensitive data.

The examples below teach the stated input contracts; fragments and deliberately incorrect counterexamples are exercises, not production implementations. Complexity counts elementary operations unless a bit-cost or allocation caveat is stated. Practice times are suggestions, not measured performance or hiring guarantees. Programs target Python 3.11+; verification uses CPython 3.12.3. Optional third-party tools and frameworks are not required for the standard-library examples.

Learning objective: Handle exceptions on purpose, read any traceback bottom-to-top in 30 seconds, and use pdb well enough that print-statement debugging feels slow.

Why this session exists

Errors are how Python tells you what went wrong. Most beginners treat them as ‘the thing that stops my program’ and reach for a broad except: to make it go away. Seniors treat them as information — each exception has a type, a message, a traceback, and a location that together tell you exactly where and why. This session teaches you to read that information, to raise exceptions on purpose (custom types, context), to catch them precisely, and to drop into pdb when the stack trace alone isn't enough. The aim is a repeatable debugging process, not a guaranteed time saving.

You will be able to

  • Read any Python traceback bottom-to-top and locate the bug in 30 seconds.

  • Raise the RIGHT exception for the situation — built-in when it fits, custom when it doesn't.

  • Use try/except/else/finally correctly, including chained exceptions with raise X from Y.

  • Drive pdb with n / s / c / l / p / w / u / d / b / q — no cheat sheet needed.

  • Know when a print-statement is right (fast!) and when pdb is right (surgical), and use the modern tools (icecream, ipdb, breakpoint()).

Prerequisites


Pre-read · skim before the session (choose readings within your time budget)

Optional original media pointers (not watched or independently verified here):

Use the official sources below for the contracts. Original channel/duration claims are not treated as verified attribution.

Further reading: Python Tutorial · Errors and Exceptions

Further reading: pdb — The Python Debugger

Further reading: Understanding the Python Traceback


(a) Intuition · 5 min

Exceptions are envelopes, not explosions

Real: Imagine a mail room. When a delivery goes wrong — wrong address, damaged parcel — the mail room doesn't burn down. It puts the parcel in an envelope with a note (‘recipient unknown, returning to sender’) and passes it back up the chain until someone knows what to do. Python exceptions are that envelope. They carry a type, a message, and a full trail (traceback) of who passed them along. Your job as a dev is to read the envelope, decide which layer of code knows what to do with it, and catch it exactly there — no earlier, no broader.

 
    Formally: an exception is an instance of a BaseException subclass raised via raise. Raising unwinds the stack until a matching except is found; an unhandled exception in a simple script normally prints a traceback and ends it. Threads, tasks, SystemExit and host runtimes have their own boundaries.
    The bugs happen when devs catch too broadly (except:) or swallow a failure before a layer can handle it. A deliberate isolation boundary is different from silently continuing corrupt work. Catch narrow, catch late, and preserve failure information for the layer that owns recovery.
  

The three rules that separate junior from senior error handling

If your exception handling breaks any of these, a reviewer will bounce your PR

  • Catch the narrowest type you can handle. except ValueError: beats except Exception: beats except:. Broad catches hide bugs.

  • Catch as late as you can — at the layer that has enough context to decide what to do. Catching in a helper and swallowing gives the caller no chance.

  • Translate failures with raise NewError(...) from original when declaring a causal link. Raising a new exception inside a handler without from normally retains the old exception under __context__; it does not automatically discard its traceback. Use bare raise to propagate the current exception unchanged.

The single most damaging except in the world

# Deliberately wrong: a successful return conceals a failure.
def swallow_bug():
    try:
        raise KeyError("misspelled field")
    except Exception:
        return "apparently successful"
 
assert swallow_bug() == "apparently successful"

A bare except: also catches KeyboardInterrupt and SystemExit. Swallowing them can prevent the expected interrupt/exit; catching for cleanup and immediately re-raising does not. Prefer finally for cleanup. A boundary may catch Exception to report a failure or protect a long-running worker, but must not turn an unhandled failure into a successful result. Tracebacks can contain sensitive values, so logging needs a redaction/access policy.

A quick history

Class-based exceptions predate Python 2.0; that release removed the -X option that had made standard exceptions strings instead of classes. Its release notes do not say that class-based exceptions first appeared in 2.0. Python 3.0 removed string exceptions and introduced explicit chaining with raise ... from .... Python 3.7 (2018) added breakpoint() (PEP 553), and 3.11 (2022) added ExceptionGroup/except* (PEP 654). Groups represent multiple failures, including those collected by asyncio.TaskGroup; their display is more complex than the single-chain traceback below.

(b) Following an error from type to traceback · 15 min

The exception hierarchy (the parts you'll actually touch)

Class-based exceptions let a handler match a family of failures rather than a message string. Locate the error you intend to recover from, then trace its ancestors to see what else a broader handler would catch.

The exception hierarchy (the parts you'll actually touch)
Figure text equivalent — labels and relationships
  • Node “BaseException” (square).
  • Node “SystemExit” (square).
  • Node “KeyboardInterrupt” (square).
  • Node “GeneratorExit” (square).
  • Node “Exception” (square).
  • Node “ArithmeticError” (square).
  • Node “LookupError” (square).
  • Node “AttributeError” (square).
  • Node “OSError” (square).
  • Node “ValueError” (square).
  • Node “TypeError” (square).
  • Node “RuntimeError” (square).
  • Node “StopIteration” (square).
  • Node “ZeroDivisionError” (square).
  • Node “KeyError” (square).
  • Node “IndexError” (square).
  • Node “FileNotFoundError” (square).
  • Node “PermissionError” (square).
  • “BaseException” leads to “SystemExit”. Line: normal; ends: arrow point.
  • “BaseException” leads to “KeyboardInterrupt”. Line: normal; ends: arrow point.
  • “BaseException” leads to “GeneratorExit”. Line: normal; ends: arrow point.
  • “BaseException” leads to “Exception”. Line: normal; ends: arrow point.
  • “Exception” leads to “ArithmeticError”. Line: normal; ends: arrow point.
  • “Exception” leads to “LookupError”. Line: normal; ends: arrow point.
  • “Exception” leads to “AttributeError”. Line: normal; ends: arrow point.
  • “Exception” leads to “OSError”. Line: normal; ends: arrow point.
  • “Exception” leads to “ValueError”. Line: normal; ends: arrow point.
  • “Exception” leads to “TypeError”. Line: normal; ends: arrow point.
  • “Exception” leads to “RuntimeError”. Line: normal; ends: arrow point.
  • “Exception” leads to “StopIteration”. Line: normal; ends: arrow point.
  • “ArithmeticError” leads to “ZeroDivisionError”. Line: normal; ends: arrow point.
  • “LookupError” leads to “KeyError”. Line: normal; ends: arrow point.
  • “LookupError” leads to “IndexError”. Line: normal; ends: arrow point.
  • “OSError” leads to “FileNotFoundError”. Line: normal; ends: arrow point.
  • “OSError” leads to “PermissionError”. Line: normal; ends: arrow point.

For application recovery, prefer specific subclasses of Exception. A handler for BaseException or a bare handler also sees interrupt/exit signals; swallowing them is usually wrong. Cleanup-and-reraise and special top-level control machinery are exceptions, not a reason for ordinary business logic to catch everything. The diagram is a selected hierarchy, not the complete modern tree (for example, it omits exception groups).

Reading a traceback bottom-to-top

Traceback (most recent call last):
  File "app.py", line 42, in <module>
    result = process(inputs)                    # ← top-of-stack caller
  File "app.py", line 30, in process
    return normalize(records)                   # ← next frame down
  File "app.py", line 18, in normalize
    return [r['name'].lower() for r in records] # ← THIS is where the error happened
AttributeError: 'NoneType' object has no attribute 'lower'

Read the LAST line first

Desc: It's the exception type + message. That tells you WHAT went wrong.

Read the file+line just above it

Desc: That's WHERE it went wrong — the innermost frame.

Skim up the stack

Desc: Caller frames appear before callee frames in an ordinary traceback. Your code and library frames can interleave; there is no reliable fixed count or top/bottom ownership rule.

Look for the first frame you own

Desc: That's usually the fix site — even if the exception was raised deeper.

try / except / else / finally — what each block does

Put only the operation whose failure you can recover from in try. An exception in else is not caught by the preceding except. Avoid return or a new exception in finally: they can override a pending return or suppress/replace the original failure. A with statement calls __exit__ only after __enter__ succeeds; acquisition code must clean up partial acquisition itself.

Name: try:

Tagline: The risky code

  • Runs top-to-bottom

  • Stops the moment an exception is raised

  • Jumps to matching except (or unwinds if none)

Name: except X:

Tagline: The recovery

  • Runs only if X is raised

  • Multiple except clauses = pattern match top-down

  • Use except X as e: to name the object

Name: else:

Tagline: The ‘it worked’ branch

  • Runs when the try suite falls through without an exception, return, break or continue

  • Great for code that must run only on success

  • Avoids wrapping too much in try

Name: finally:

Tagline: The cleanup

  • Runs during normal Python control transfer, including exception/return; abrupt process termination can bypass it

  • Perfect for closing files, connections, locks

  • Modern replacement: context managers (with)

The pdb keystroke cheat sheet

A small working command vocabulary

Name: n · next

Desc: Execute current line, stop at next line in SAME function.

Name: s · step

Desc: Execute current line, stop at the FIRST line of any function it calls. Use to dive in.

Name: c · continue

Desc: Resume until next breakpoint or program end.

Name: r · return

Desc: Continue until the current function returns. Great for skipping loop bodies.

Name: l · list

Desc: Show the source around the current line. ll for the whole function.

Name: p / pp

Desc: Print / pretty-print an expression. p len(items), pp big_dict.

Name: w · where (also bt)

Desc: Show the current call stack. Useful when deeply nested.

Name: u · up / d · down

Desc: Select an older caller (u) or newer callee (d) frame for inspection; this does not move execution backward or forward.

Name: b · break

Desc: b app.py:42 sets a breakpoint. b alone lists breakpoints.

Name: cl · clear

Desc: Remove breakpoints. cl 1 removes bp #1.

Name: ! <stmt>

Desc: Run any Python statement in the current frame. !items.append(99) is legal.

Name: q · quit

Desc: Abort the program. Ctrl-D also works.

The mental model to hold

Read the envelope, then act on it

A raised exception provides a type and diagnostic arguments/message, plus a traceback after propagation; a newly constructed, unraised exception can have no traceback. Any handling that drops one of the three is a smell. Record an explicit success/failure outcome at the boundary; log only policy-approved context and raise custom exceptions with context in your business logic; catch precisely; use pdb when the traceback isn't enough.

Myth: "Exceptions are for errors, so the defensive thing to do is wrap risky code in try/except Exception and log it. Catching more is safer than catching less."

Truth: A broad catch that silently continues can convert a loud, localised failure into a silent wrong answer that surfaces somewhere else, much later, with no stack trace pointing at the cause. Catch only the exceptions you can actually handle — meaning you have a specific recovery action, not just a log line.

Why: Because it works in the demo. The script stops crashing, the log gets a line, and the shift ends. What it hides is that except Exception swallows KeyError from your own typo, AttributeError from a None you didn't expect, and MemoryError — bugs, not conditions — and the pipeline continues with a half-built result. In batch data work this is the worst possible failure mode: a crashed job can be retried under an explicit policy, but a job that "succeeded" while quietly dropping every malformed record writes bad data downstream and nobody looks. The narrow case where the myth is true — a top-level handler in a long-running service that must not die — is real, which is exactly why the habit spreads to places where it isn't.

Test a broad catch eating a typo:

rows = [{"id": 1, "value": 10}, {"id": 2}]
total = 0
for row in rows:
    try:
        total += row["vlaue"]  # typo: intentional negative control
    except Exception:
        continue
assert total == 0  # not evidence of a correct aggregation

Merely changing this to except KeyError still catches the typo. Validate the intended input schema explicitly, and let unexpected programmer errors fail rather than classifying every missing lookup as dirty input.

Question: Why does a stack trace read bottom-up — the line that actually failed printed last? And why is raise ... from e more than cosmetic? Derive both from how exceptions propagate.

Premise: Calling a function pushes a frame recording the caller's position, so control can return. The call stack is therefore a record of how you got here.

Because: a return address must be stored somewhere, and a stack is the natural structure for nesting

Premise: When an exception is raised, the runtime walks that stack outward looking for a handler, popping frames as it goes.

Because: if the current frame can't handle it, only an ancestor can — there is nowhere else to look

Traceback objects record the propagation chain of the exception; the ordinary display shows caller frames before the innermost failing frame.

Because: the exception object travels outward and records where it has been

Premise: The ordinary display orders caller frames before the innermost failing frame and then prints the exception type/message. Chained exceptions and exception groups add additional structure.

Because: this is the documented traceback presentation; it need not be explained by an invented terminal-design history

Premise: A new exception raised during handling retains implicit __context__; explicit from also supplies __cause__. The default display chooses cause or unsuppressed context; from None hides context in that display without erasing it.

Because: the new exception explains what the caller should do; the original explains why, and debugging needs both

Conclusion: A traceback records frames traversed by a failure. Start at the final exception, inspect its failing frame and relevant callers, and read any earlier cause/context too; library frames may expose a violated contract. Note: raise NewError(...) from e preserves the original trace under "The above exception was the direct cause", while a new raise NewError(...) inside an except without from shows it under "During handling ... another exception occurred" — different display conventions: an explicit cause declares intent, but implicit context alone does not prove the handler has a bug. It also predicts why except: pass is uniquely destructive: it discards the only record of the path that produced the error.

Name: Errors are values that travel outward

Picture: Picture an exception as a package thrown outward through the call stack, stopping at the first frame that declared it will accept that type. Every frame it passes through stamps its address on the package. When it reaches the top with no taker, the runtime prints the stamps and stops the simple script. So designing error handling is really designing catch altitude: how far out should this travel before someone deals with it? Too low and you handle things you don't understand; too high and you lose the context needed to recover.

  • Catch at the level that has enough context to decide. A parser knows a row is malformed; only the caller knows whether to skip it, quarantine it, or fail the batch.

  • Catch a type for which this layer has a defined policy. Broad Exception catches can be justified at isolation boundaries, but must produce an explicit failure/recovery outcome rather than false success.

  • Use finally or context managers for cleanup — they handle ordinary success/failure exits. Abrupt process termination can bypass Python cleanup, and a context manager only releases the resource it actually acquired.

  • Debugging order: read the traceback bottom-up, reproduce it deterministically, bisect the input, then use a debugger (breakpoint()) — printing is useful for a one-shot value; a debugger is useful for control flow and caller state. Neither is universally faster.

Trigger: Fire this model the moment you see: except: pass · a job that "succeeded" with suspiciously few output rows · a log line saying "error occurred" with no type or trace · a retry loop with no backoff and no exception filter · an error message naming a library file you have never opened.

Decision: A batch job hits a malformed record on row 4 million of 10 million. Fail fast, skip and continue, or quarantine and continue?

Name: Fail fast

Gain: the failure is loud and attributable; a transactional or staged output policy can prevent partial data reaching consumers; the stack trace is still warm and the input that caused it is identifiable

Cost: partial work may need replay; one poisoned record can block the pipeline and trigger a configured alert; retries repeat the same failure unless the input changes

Pick: correctness dominates availability — financial, billing, or any output another team treats as authoritative — or the error type suggests a bug rather than dirty data

Name: Skip and count

Gain: the job can complete with explicitly counted omissions; a rejection counter plus a threshold alert catches systematic breakage without blocking on one-offs

Cost: silent data loss unless someone watches the counter; a schema change upstream can drop a large fraction while still "succeeding" if the threshold is loose

Pick: the consumer tolerates approximation (dashboards, aggregate metrics) and you enforce a hard threshold — abort if rejects exceed a fixed small percentage

Name: Quarantine (dead-letter) and continue

Gain: a durable quarantine can preserve rejected records for inspection and replay if the write succeeds; storage failure or backpressure must have an explicit policy

Cost: you must build and operate the quarantine path — storage, schema, alerting, and a replay mechanism — and an unwatched dead-letter queue is just slower data loss

Pick: the pipeline is long-lived and ingests data you do not control; use it only with retention, privacy, durable-write and replay policies

Verdict: Fail fast during development and on anything authoritative; quarantine in production ingestion. What separates the two in practice is not the code path but the observability: skipping is only acceptable when the skip count is a first-class metric with a threshold that pages someone. Keep the distinction sharp between the two error classes. Dirty input is an expected condition and deserves a policy. A KeyError from your own code is a bug and deserves a crash — routing it through the same tolerant path is how a pipeline ends up emitting zeros for a month.


(c) Hands-on · 25 min

Save as s011_errors.py, run.

"""s011_errors.py — exceptions, debugging, and the modern toolkit."""
from __future__ import annotations
from contextlib import contextmanager
from typing import Iterable
import json
import logging
import traceback
import sys
 
logging.basicConfig(
    level=logging.INFO,
    format="%(levelname)s [%(name)s] %(message)s",
)
log = logging.getLogger("s011")
 
# =========================
# Part 1 · try / except / else / finally
# =========================
print("--- Part 1 · try/except/else/finally ---")
 
def parse_int_safe(s: str) -> int | None:
    try:
        n = int(s)
    except ValueError:
        log.warning("not an int: %r", s)
        return None
    else:
        log.info("parsed cleanly: %r → %d", s, n)
        return n
    finally:
        log.debug("attempted parse of %r", s)
 
print(parse_int_safe("42"))
print(parse_int_safe("nope"))
 
# =========================
# Part 2 · Custom exceptions with context
# =========================
print("\n--- Part 2 · custom exceptions ---")
 
class DomainError(Exception):
    """Base for all domain errors in this app."""
 
class OrderError(DomainError):
    def __init__(self, order_id: str, reason: str) -> None:
        super().__init__(f"order {order_id!r}: {reason}")
        self.order_id = order_id
        self.reason = reason
 
class OrderNotFound(OrderError):
    def __init__(self, order_id: str) -> None:
        super().__init__(order_id, "not found")
 
try:
    raise OrderNotFound("O-1234")
except OrderError as e:
    log.error("caught %s (id=%s reason=%s)", type(e).__name__, e.order_id, e.reason)
 
# Callers can catch broadly (DomainError) or narrowly (OrderNotFound) as needed.
 
# =========================
# Part 3 · Exception chaining — `raise ... from ...`
# =========================
print("\n--- Part 3 · chaining ---")
 
def load_order(raw: str) -> dict:
    try:
        order = json.loads(raw)
    except json.JSONDecodeError as e:
        raise OrderError("<unknown>", "malformed JSON") from e
    if not isinstance(order, dict):
        raise OrderError("<unknown>", "expected a JSON object")
    return order
 
try:
    load_order("{not-json")
except OrderError as e:
    log.error("chained: %s", e)
    log.error("root cause: %s", e.__cause__)   # the original JSONDecodeError
 
# NEVER lose the original traceback. `raise NewError(...) from e` preserves it.
 
# =========================
# Part 4 · Context managers replace try/finally
# =========================
print("\n--- Part 4 · context managers ---")
 
@contextmanager
def open_resource(path: str):
    log.info("acquiring %s", path)
    from io import StringIO
    f = StringIO()  # in-memory resource for this exercise
    try:
        yield f
    finally:
        log.info("releasing %s", path)
        f.close()
 
with open_resource("in-memory") as f:
    f.write("hello from context manager\n")
 
# Same guarantee as try/finally, expressed once at the resource layer.
 
# =========================
# Part 5 · What to do with an exception you can't handle
# =========================
print("\n--- Part 5 · logging vs re-raising ---")
 
def process_all(items: Iterable[dict]) -> list[dict]:
    """Log per-item errors but keep processing. Return successes only."""
    out: list[dict] = []
    for i, item in enumerate(items):
        try:
            out.append(process_one(item))
        except DomainError:
            log.exception("skipping item %d", i)   # logs message + traceback
    return out
 
def process_one(item: dict) -> dict:
    if "id" not in item:
        raise OrderError("<missing>", "no id field")
    return {"id": item["id"], "ok": True}
 
print(process_all([{"id": 1}, {"broken": True}, {"id": 3}]))
 
# =========================
# Part 6 · breakpoint() — pdb without the import dance
# =========================
print("\n--- Part 6 · breakpoint() ---")
 
def buggy_average(values: list[float]) -> float:
    total = sum(values)
    # Uncomment the next line to drop into pdb here:
    # breakpoint()
    return total / len(values)   # ZeroDivisionError if values is empty
 
try:
    print(buggy_average([]))
except ZeroDivisionError:
    log.exception("buggy_average failed — traceback:")
    # An enabled breakpoint above stops inside buggy_average, before division.
    # Try: n (next), p total (print), p values, l (list), q (quit).
 
# =========================
# Part 7 · Reading a traceback like a pro
# =========================
print("\n--- Part 7 · traceback inspection ---")
 
def a(): b()
def b(): c()
def c(): raise ValueError("boom!")
 
try:
    a()
except ValueError:
    print("--- traceback ---")
    traceback.print_exc()
    print("--- summary ---")
    for frame in traceback.extract_tb(sys.exc_info()[2]):
        print(f"  {frame.filename}:{frame.lineno} in {frame.name}: {frame.line}")
 
# You now know where + how you got there, programmatically.
# `logging.exception` and `logger.error(..., exc_info=True)` capture the same.
 
# =========================
# Part 8 · Modern niceties
# =========================
print("\n--- Part 8 · modern tools ---")
 
# icecream (pip install icecream): print with variable names + source line.
# from icecream import ic
# ic(some_var)   # → "ic| some_var: value"
 
# ipdb (pip install ipdb): pdb with IPython niceties (colors, tab-completion).
# breakpoint() will pick up ipdb if PYTHONBREAKPOINT=ipdb.set_trace
 
# structlog (pip install structlog): structured logging (JSON) instead of f-strings.
# Vital in production so log aggregators can grep by field.
 
log.info("End of session — record carries extra fields", extra={"session": "s011", "ok": True})
# This plain formatter does not display session/ok or serialize JSON.
# A formatter/handler must explicitly emit those fields; extra alone is not JSON logging.

What each block teaches

Anatomy of the exercises

Name: Part 1 · try/except/else/finally

Desc: else runs on normal fall-through, not after an early return. finally handles ordinary exits; avoid control-flow statements there that override a failure.

Name: Part 2 · custom exceptions

Desc: A tiny hierarchy: DomainError → OrderError → OrderNotFound. Callers pick their granularity. Attach context via __init__.

Name: Part 3 · chaining

Desc: raise X from e declares __cause__; implicit chaining uses __context__. str(e) alone is only a message, but a new exception raised inside the handler still receives implicit context.

Name: Part 4 · context managers

Desc: @contextmanager puts setup/yield/cleanup in open_resource. This example owns an in-memory StringIO; it opens no file and acquires no lock. An exception from the block is thrown into the generator at yield.

Name: Part 5 · log or re-raise

Desc: Part 5 is an explicit reject-and-continue demonstration: only DomainError is skipped, and successes are returned. A production API should also report reject counts/results and apply a threshold or quarantine policy; logging alone is not proof of success.

Name: Part 6 · breakpoint()

Desc: PEP 553 makes it a builtin. Set PYTHONBREAKPOINT=ipdb.set_trace to use ipdb; set to 0 to disable in prod.

Name: Part 7 · traceback API

Desc: traceback.extract_tb, traceback.format_exc() — programmatic access to the info you'd read manually.

Name: Part 8 · modern tools

Desc: icecream for smarter print. ipdb for a nicer pdb. structlog for JSON logs. All optional; all worth knowing.

What to observe

Part 2 records the exception class and selected synthetic context. In a real application, avoid logging raw customer identifiers/payloads by default. Part 3's __cause__ chain is the reason the JSON error isn't lost when re-raised as an OrderError. Part 5's log.exception includes the full traceback in the log. log.error(msg) does not unless given exc_info=True. Include traceback information only when useful and permitted by the logging policy. Part 6's uncommented breakpoint() would open pdb where you can inspect values, total, and step around.

Goal: Debug a real error with pdb — memorise n / s / c / p / l / w / q

Save this to debug_me.py and run it:

def find_max(items):
    m = items[0]
    for x in items:
        if x > m:
            m = x
    return m
 
def summarise(rows):
    return {"max": find_max(rows), "count": len(rows)}
 
breakpoint()
print(summarise([]))       # will crash — figure out why using pdb

At the pdb prompt:

  1. On Python 3.12, breakpoint() stops before the following print line; inspect l first. Do not n over that line, because it runs the failing call without entering it.
  2. s (step) to dive INTO summarise.
  3. l (list) to see the surrounding code.
  4. p rows to see the argument.
  5. Use s until the selected frame is find_max; on Python 3.12, the function-entry stop in summarise takes one step to its return line and another to enter find_max.
  6. p items — see what you have.
  7. p items[0] — now you know the exception.
  8. w (where) to see the stack.
  9. u to go back up to summarise, inspect state.
  10. q to quit.

Now fix find_max to raise a friendlier ValueError when items is empty; the worked repair follows. The original failure is IndexError at items[0], not a comparison bug.


Worked failure-path and chaining experiments

This bounded experiment observes the order of events. else is not “all successful returns”: it runs only on fall-through. finally still runs for an early return. Exceptions raised in else bypass that try statement's preceding handlers.

def flow(mode):
    events = []
    try:
        events.append("try")
        if mode == "error":
            raise ValueError("expected")
        if mode == "return":
            return events
    except ValueError:
        events.append("except")
    else:
        events.append("else")
    finally:
        events.append("finally")
    return events
 
assert flow("ok") == ["try", "else", "finally"]
assert flow("error") == ["try", "except", "finally"]
assert flow("return") == ["try", "finally"]
 
def translated(mode):
    try:
        int("bad")
    except ValueError as original:
        if mode == "explicit":
            raise RuntimeError("translated") from original
        if mode == "hidden":
            raise RuntimeError("translated") from None
        raise RuntimeError("translated")
 
for mode in ["explicit", "implicit", "hidden"]:
    try:
        translated(mode)
    except RuntimeError as error:
        assert isinstance(error.__context__, ValueError)
        assert (error.__cause__ is error.__context__) == (mode == "explicit")
        assert error.__suppress_context__ == (mode != "implicit")

Both implicit and explicit chains retain the original exception object and its traceback; their default display labels differ. from None is a display decision, not destruction of the retained context. Cleanup can also replace a pending failure if it raises: keep cleanup simple and inspect chains rather than declaring the last line the whole story.

Worked find_max repair and debugger interpretation

Keep the nonempty scan, but reject empty input before indexing. This is a finite comparable-sequence exercise, not a general streaming/NaN-aware maximum.

def find_max_checked(items):
    if not items:
        raise ValueError("find_max needs at least one item")
    m = items[0]
    for x in items[1:]:
        if x > m:
            m = x
    return m
 
assert find_max_checked([-7, -2, -9]) == -2
try:
    find_max_checked([])
except ValueError as error:
    assert str(error) == "find_max needs at least one item"
else:
    raise AssertionError("empty input must fail explicitly")

The repair scans linearly and the displayed slice uses linear auxiliary space; iterate over all items (including the first again) to avoid that copy. In the original pdb exercise, evaluating p items[0] reports an IndexError from the debugger expression without advancing the program; stepping the program's indexing line reproduces the program failure. u/d only change the selected frame. q aborts rather than repairs the program. For post-mortem inspection without editing source, python3 -m pdb debug_me.py lets an unhandled exception enter the debugger. Optional ipdb/icecream/structlog snippets are configuration suggestions, not executed dependency integrations.

(d) Production reality · 15 min

Three failure-reporting mechanisms

A swallowed error becomes a false success. The swallow_bug counterexample returns normally after a programmer error. Catch only expected provider failures in a local helper; unexpected failures should escape to the task boundary. Reporting a redacted failure there is different from continuing a corrupt calculation.

A task's return path is a contract. A wrapper that catches every exception and returns None can make a failed operation look like a normal result. Re-raise or return a typed failure that the caller checks. In task queues, retries and alerts require explicit configuration and idempotency; re-raising alone does not guarantee automatic retries or paging. This is a mechanism, not a reproduced Celery deployment incident.

Observe the transition, not only snapshots. In debug_me.py, rows is empty before find_max reads index zero. Stepping into that call and inspecting items explains the failure. w identifies the caller and u inspects its argument without rewinding execution. Print statements can answer a quick value question; neither a “four-minute fix” nor a 10× debugger speedup is established here.

Where this shows up in the rest of the plan

Root: Error handling and debugging are cross-cutting skills for every future session

Label: Modules & packaging

Desc: ImportError, ModuleNotFoundError — everyday debugging surface for real projects.

Label: Testing

Desc: pytest's --pdb enters debugging on failures; combine with -x when you want to stop after the first. This optional integration is not executed here.

Label: FastAPI / Flask

Desc: HTTPException classes, exception handlers, and correlation IDs live here.

Label: asyncio

Desc: ExceptionGroup (3.11) is the new shape for concurrent errors. TaskGroups use it.

Label: Observability & SRE

Desc: Structured logging (structlog), Sentry integration, and error budgets build on today's habits.

Label: Code review

Desc: ‘Why are you catching Exception here?’ is the single most common review comment.


(e) Recall + stretch · 10 min

Self-check (answer follows each question).

Recall — questions and worked answers

Q: Why should you almost never write except: or except BaseException:?

A: Both also catch KeyboardInterrupt and SystemExit; swallowing those signals can defeat interruption or exit. Cleanup-and-reraise is different from swallowing. Prefer finally for cleanup and specific Exception subclasses for recovery.

Q: What's the difference between raise NewError() and raise NewError() from original?

A: Inside a handler, a new exception normally retains the active exception as __context__. from original additionally sets __cause__ and requests an explicit-cause display. from None suppresses context in the default display but retains it for inspection. Use bare raise for unchanged propagation; explicit chaining is for intentional translation.

Q: When would you use else in a try/except?

A: Put follow-up work in else when the try suite falls through without an exception or early return/break/continue. Its own exceptions are not caught by the preceding handlers. The flow experiment above demonstrates the distinction.

Q: What's the difference between log.error(msg) and log.exception(msg)?

A: log.exception(msg) includes current exception information (normally called in a handler). log.error(msg, exc_info=True) can do the same; plain log.error(msg) does not. Redaction and expected-error policy decide whether a full traceback belongs in the log.

Q: Design a custom exception PaymentError with a subclass InsufficientFundsError. What should the base class carry?

A: Carry structured, policy-safe context separately from the message:

class PaymentError(Exception):
    def __init__(self, account_id: str, reason: str):
        super().__init__(f"payment failed for {account_id}: {reason}")
        self.account_id = account_id
        self.reason = reason
 
class InsufficientFundsError(PaymentError):
    def __init__(self, account_id: str, missing_cents: int):
        super().__init__(account_id, f"missing {missing_cents}c")
        self.missing_cents = missing_cents

These synthetic IDs illustrate the API; a real message/log may need a redacted ID or correlation token.

Q: In pdb, what do n, s, c, w, u, and d do?

A: n — next line in current function. s — step INTO the next function call. c — continue until next breakpoint. w — where (print stack). u — go up one frame. d — go down one frame. Use these six to inspect state and call flow; selecting a frame does not resume execution.

Q: How do you drop into pdb without editing the source file?

A: Run with python -m pdb script.py. The debugger starts at line 1. Type c to run until an unhandled exception, or b module.py:42 to set a breakpoint and then c.

Q: What's contextmanager from contextlib, and why is it better than try/finally scattered through your code?

A: A decorator that lets you write a with-block resource manager using a single generator function (yield in the middle = the ‘as’ target). The resource factory defines setup and cleanup once; callers use with x() as y:. The decorator does not invent cleanup: the generator still needs its own correct try/finally and exactly one yield.

Q: What's the modern replacement for import pdb; pdb.set_trace()?

A: breakpoint() — a builtin since Python 3.7. Respects the PYTHONBREAKPOINT env var so you can swap in ipdb (nicer UI) or disable it in production (PYTHONBREAKPOINT=0).

Q: Stretch — a service you own has to call three flaky APIs in sequence. Design the exception strategy: which layer catches what, what gets logged, what gets re-raised, and what the top-level caller sees.

Stretch: true

A: Translate specific provider failures into domain exceptions with explicit causes. Retry only identified transient failures, with a bounded attempt/deadline budget and an idempotency policy; do not replay already completed side effects blindly. The request boundary can map an upstream failure to a deliberate HTTP status (for example 502), expose only a correlation ID, and record policy-approved diagnostic context. Caller-visible errors, protected logs and metrics are distinct outputs. This is a design, not an executed API integration.

Explain-out-loud test

If you can't teach these three without notes, redo the session:

  1. Why is except: almost always wrong? (name what it catches that you don't want)
  2. What does raise X from Y do differently from raise X? (one word: chaining)
  3. List the six pdb commands you'll actually use. (n, s, c, w, u, d — say what each does)

Model answers: (1) Bare except also catches interrupt/exit signals; swallowing them can defeat cancellation. (2) Explicit from sets __cause__, while raising inside a handler normally retains __context__ even without it. (3) n steps over, s steps into, c resumes, w shows frames, and u/d select caller/callee frames without moving execution.


What comes next

Hub: The 6-Month Learning Plan


Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.

Corrective source notes

Official Python 3.12 documentation snapshots checked 2026-10-09 support these specific claims; they are mutable maintenance pages, not whole-lesson certification. Local tests are finite; optional media, static type checking, external frameworks, application rendering and deployment are separate unperformed checks.

Pause / Recall / Apply

Can you explain it without the page?

Close the example. Reconstruct the core idea, then change one assumption. Mark complete when you’re ready; you can always undo it.

Stored in this browser only. No account, no sync. Clearing browser data removes your record.