Overview
Prerequisites
- Prior topics: 4 · Just Enough Python -- every
script in this topic is fully type-annotated Python, and you should already be comfortable reading
functions, classes, exceptions, and
list/dictliterals the way that primer taught them; 5 · Just Enough Bash -- every example is driven from a terminal, and several examples pipe commands, background a process with&, or read an exit code the way that primer taught; 15 · Software Testing -- this topic assumes you can already write a failing pytest-style assertion and read a traceback; a test tells you that something is wrong, this topic is about finding where and why, then measuring whether a fix actually helped. - Tools & environment: a macOS/Linux terminal; Python 3.13.12 (stdlib
pdb,cProfile,pstats,tracemalloc,threading,asyncio,sys.monitoring-- no extra install needed) plus py-spy 0.4.2, debugpy 1.8.21, line_profiler 5.0.2 (provideskernprof), hypothesis 6.156.6, gprof2dot 2025.4.14, and snakeviz 2.2.2 from PyPI; Python 3.14.3 for the two examples that need pdb's 3.14-only remote-attach andset_trace_asyncfeatures; git 2.39.5+ for the bisection examples; inferno (cargo install inferno) for flame-graph rendering; on Linux, gdb 17.2+ (bundles Python-awareness viapython-gdb.py) and perf (ships with the Linux kernel'slinux-toolspackage) for the native-debugging tier; on macOS, lldb (ships with Xcode Command Line Tools) plus cpython_lldb 0.3.3 from PyPI. - Assumed knowledge: reading and writing typed Python functions and classes; running a program
and reading its exit code from a Bash terminal; writing a plain
assert-based test. No prior debugging or profiling background is required -- this topic is where that background starts.
Why this exists -- the big idea
The problem before the solution: a failing test tells you something is wrong but not where or
why; print-driven guessing scales poorly as a codebase grows, and optimizing by hunch tunes the
wrong 90% of the code while the real bottleneck sits untouched, unmeasured. The one idea worth
keeping if you forget everything else: debugging is a search -- form a falsifiable hypothesis,
change exactly one thing, observe, and halve the remaining search space; performance work is
measure-first, because the hot spot is almost never where you would have guessed by reading the code.
Cross-cutting big ideas, taught here and then reused for the rest of this topic:
layering-and-leaks -- a bug or a hot spot usually lives at a seam between layers (your code, the
runtime, the OS, the CPU cache), and a profiler or a native-aware debugger is how you see through
that layer instead of guessing at it (the Native & Systems tier makes this literal: the same slow
function looks completely different from cProfile's Python-only view versus a native-aware view).
determinism-vs-emergence -- the hardest bugs are emergent, not local: a data race or an asyncio
interleaving bug depends on the interaction between threads or tasks, not on any single line, so it
is only reproducible by controlling that interaction (a forced yield, a seed, a barrier), never
merely by rereading the code closely enough.
Install and run your first example
Confirm the core toolchain this topic's Beginner tier uses is installed:
$ python3 --version
Python 3.13.12
$ python3 -m pdb --help > /dev/null && echo "pdb OK"
pdb OK
$ python3 -c "import cProfile, pstats, tracemalloc; print('stdlib profiling OK')"
stdlib profiling OK
$ git --version
git version 2.39.5Install the packages the Intermediate/Advanced/Native & Systems tiers need with one pip command
(ideally into a virtual environment):
pip install py-spy==0.4.2 debugpy==1.8.21 line_profiler==5.0.2 hypothesis==6.156.6 \
gprof2dot==2025.4.14 snakeviz==2.2.2A note on versions: this topic's examples were authored and verified against these exact tool
versions, in this sandbox, on 2026-07-15. pdb, cProfile, tracemalloc, and git bisect are
stdlib/core-tool features that have been stable for many releases; the two examples that need Python
3.14's remote-attach (python -m pdb -p <pid>) and pdb.set_trace_async() are called out explicitly
where they appear.
Every example is a complete, self-contained runnable file (or command sequence) colocated under
learning/code/, actually executed to capture its documented output. Where an example genuinely
needs a privilege or a host this sandbox does not have (root access for py-spy on macOS, a Linux
kernel for perf, macOS Developer Mode for lldb attach), that limitation is stated honestly, backed
by the tool's own error message or documentation, rather than a fabricated transcript standing in for
it -- see the Native & Systems tier's opening note for the full, itemized
account of every such limitation and its real, disclosed substitute.
How this topic's examples are organized
- Beginner (Examples 1-27) --
pdb's core vocabulary: breakpoints, stepping, the call stack, conditional breaks,display, post-mortem debugging,print/logging versus interactive debugging, plus a first pass throughcProfile,tracemalloc, flame-graph reading, andgit bisectby hand. - Intermediate (Examples 28-49) --
py-spy'stop/record/dump(and the honest substitute used where root access is unavailable), sortingpstatsbytottimeversuscumtime, line-level profiling withkernprof,tracemallocdiffing,debugpy/DAP remote attach, automatedgit bisect run, and delta-debugging with a hand-rolledddminand with Hypothesis's shrinker. - Advanced (Examples 50-62) --
pdb'sinteractmode and chained-exceptionexceptionscommand, multi-module log correlation, converting a profile to a flame graph viagprof2dot, a real Neovim DAP session, a documented before/aftercProfilemeasurement, a genuine threading race reproduced and fixed, anasynciointerleaving bug,pdb.set_trace_async(),multiprocessingversusthreadingunder load, automated bisection of a performance regression, and delta-debugging a 10,000-line crash. - Native & Systems (Examples 63-80) --
gdb/lldb/perf-level native-aware debugging and profiling of the CPython process itself (with every host limitation disclosed honestly), plus the topic's integrative discipline examples: a combined correctness-and-performance bug, the recursivetottime-versus-cumtimetrap, an unbounded-cache leak, import-time profiling, lock contention under load, a flame-graph diff, deterministic seeding for a flaky bug, bisecting with a flaky-test guard, and a low-overheadsys.monitoringtracer. - Capstone -- one repository carrying both a seeded correctness bug and a seeded performance bug, resolved end to end: bisect and minimize the failing input, fix it with a debugger-guided, test-first change, then profile (two independent ways), fix, and measure the hot path with zero regressions.
The 23 concepts this topic covers
- co-01 · Interactive breakpoints and stepping -- pausing execution at a chosen line and driving it forward with step-into / step-over / step-out, versus letting it run to completion. Examples 1-3, 14, 25-26, 39, 54, 59.
- co-02 · Conditional and watch breakpoints -- halting only when a predicate holds (
i == 47,id(obj) == ...) or a value changes, instead of stopping on every hit. Examples 8-11, 38-39. - co-03 · Call-stack, frame, and variable inspection -- reading the stack, moving between frames, and printing or mutating locals at a paused point to test a hypothesis. Examples 4-7, 25, 32, 50-51, 64.
- co-04 · Post-mortem debugging -- entering a debugger on an already-thrown, uncaught exception
(
pdb.pm(),python -m pdb) to inspect the failing frame without a rerun. Examples 15-16, 42, 51, 66, 72. - co-05 · Print/logging versus interactive debugging -- when
print/loggingbeats a breakpoint (long-lived processes, production, timing) and when it does not (one-off, deep state). Examples 12-13, 52. - co-06 · Remote and DAP debugging -- attaching a debugger to an already-running process over the
Debug Adapter Protocol (
debugpy, editor DAP) orpdb -p <pid>. Examples 40-42, 54, 59. - co-07 · Scientific-method debugging loop -- expected-vs-actual, one falsifiable hypothesis, one change, observe, repeat -- debugging as controlled experiment, not guessing. Examples 7, 23, 50, 78.
- co-08 · Bisection search as a general strategy -- halving the search space (commits, input, code region) to localize a fault in logarithmic rather than linear steps. Examples 22, 24, 43.
- co-09 ·
git bisect, manual -- drivinggit bisect start/good/badby hand to name the commit that introduced a regression. Examples 22, 72. - co-10 ·
git bisect, automated --git bisect run <script>with a pass/fail (or 125-skip) exit code so the whole search runs unattended. Examples 43-44, 61, 72, 79. - co-11 · Delta-debugging input minimization -- shrinking a failing input to a 1-minimal
reproducer (hand-rolled
ddmin, Hypothesis shrinking) so every remaining piece is necessary. Examples 24, 45-47, 62. - co-12 · Sampling versus instrumenting profilers -- periodic stack sampling (low overhead, statistical) versus per-call instrumentation (exact counts, high overhead), and when each is right. Examples 28-29.
- co-13 · CPU profiling with
cProfile-- the stdlib instrumenting profiler: running it from the CLI or programmatically and reading itspstatstable. Examples 17-18, 28, 33, 35, 53, 55, 70, 72-73, 75. - co-14 · CPU profiling with
py-spy-- a sampling profiler that attaches to a live PID with no code changes (top,record,dump,--native) -- and the honest, disclosed substitute (mini_sampler.py, built fromsys._current_frames()) used wherever root access is unavailable. Examples 28-32, 60, 71. - co-15 · Wall-clock versus CPU time -- distinguishing elapsed time (
perf_counter) from on-CPU time (process_time); I/O-bound versus CPU-bound diagnosis. Examples 19, 60, 76. - co-16 ·
tottimeversuscumtime-- a function's own time versus time including callees, and which one to optimize (many-cheap leaf versus one-expensive parent). Examples 18, 33, 73. - co-17 · Memory profiling with
tracemalloc-- snapshotting allocations, diffing two snapshots to find a leak, and widening the traceback (nframe) to split allocation sites. Examples 20, 36-37, 74. - co-18 · Line-level profiling -- attributing cost to individual source lines (
line_profiler's@profilepluskernprof), one resolution finer than function-level. Examples 34-35. - co-19 · Flame-graph reading -- width = total time in a call subtree, height = stack depth; finding the widest frame (the hot spot), not the tallest stack. Examples 21, 30-31, 53, 68-69, 71, 77.
- co-20 · Race and heisenbug reproduction -- forcing nondeterministic concurrency bugs to reproduce reliably (forced yields, seeds, barriers) before attempting a fix. Examples 56-58, 76, 78-79.
- co-21 · Load-representative versus toy profiling -- profiling at realistic scale and concurrency, because the hot spot at 100 items or a single call differs from production. Examples 48-49, 60, 76.
- co-22 · Native-layer costs and native debugging -- seeing costs the interpreter hides:
gdb/lldbPython-aware backtraces,perfwith Python perf-maps,py-spy --native, core dumps. Examples 63-68, 70-71, 75, 80. - co-23 · Before/after measurement discipline -- proving a fix with a repeated, documented measurement, and confirming zero regressions. Examples 27, 55, 57, 72, 74-75, 77, 80.
Examples by Level
Beginner (Examples 1–27)
- Example 1: First Breakpoint with breakpoint()
- Example 2: Step Into vs. Step Over
- Example 3: Step Out of a Deep Call
- Example 4: Reading the Call Stack with w
- Example 5: Navigating Frames with up/down
- Example 6: Inspecting Locals with p and pp
- Example 7: Mutating a Variable to Confirm a Hypothesis
- Example 8: Conditional Breakpoint in a Loop
- Example 9: The condition Command on an Existing Breakpoint
- Example 10: Watching a Variable with display
- Example 11: The display In-Place-Mutation Caveat
- Example 12: Print-Debugging a One-Off Script
- Example 13: logging vs. print in a Long-Lived Process
- Example 14: The breakpoint() Builtin and PYTHONBREAKPOINT
- Example 15: First Post-Mortem with python -m pdb
- Example 16: pdb.pm() in a REPL Session
- Example 17: First cProfile Run from the Command Line
- Example 18: cProfile Programmatic API with pstats
- Example 19: Wall Time vs. CPU Time
- Example 20: First tracemalloc Snapshot
- Example 21: Reading a Pregenerated Flame Graph
- Example 22: git bisect by Hand
- Example 23: Rubber-Duck Hypothesis Writing
- Example 24: Minimizing a Failing Input by Hand
- Example 25: Sticky Mode and the list Command
- Example 26: tbreak: a One-Shot Breakpoint
- Example 27: Before/After Timing a One-Line Fix
Intermediate (Examples 28–49)
- Example 28: Profiling Two Ways -- Sampling and Instrumenting
- Example 29: py-spy top on a Live Process
- Example 30: A Flame Graph from a Sampling Profiler
- Example 31: A Speedscope Export
- Example 32: py-spy dump on a Hung Process
- Example 33: Sorting pstats -- tottime vs. cumtime
- Example 34: Line-Level Profiling with kernprof
- Example 35: Line Profiler vs. Function-Level Profiler
- Example 36: A tracemalloc Snapshot Diff for a Leak
- Example 37: Widening the tracemalloc Traceback with nframe
- Example 38: Conditional Breakpoint on Object Identity
- Example 39: commands Attached to a Breakpoint
- Example 40: debugpy Attach to a Running Server
- Example 41: debugpy wait_for_client vs. Attach Later
- Example 42: pdb Remote Attach by PID (3.14+)
- Example 43: git bisect run, Automated
- Example 44: git bisect run with a Skip (Exit 125)
- Example 45: Delta-Debugging a JSON Payload
- Example 46: Delta-Debugging a Long String
- Example 47: Hypothesis Shrinking as Delta-Debugging
- Example 48: Profiling Toy vs. Realistic Input
- Example 49: Profiling Under Concurrent Load
Advanced (Examples 50–62)
- Example 50: pdb's interact Mode
- Example 51: pdb's exceptions Command, Chained
- Example 52: A Multi-Module Logging Bug
- Example 53: cProfile to a Flame Graph, Cross-Checked
- Example 54: A Real Neovim DAP Breakpoint
- Example 55: Before/After with cProfile
- Example 56: Reproducing a Threading Race
- Example 57: Fixing the Race with a Lock
- Example 58: An asyncio Interleaving Bug
- Example 59: pdb.set_trace_async() (3.14+)
- Example 60: multiprocessing vs. threading Under Load
- Example 61: git bisect run for a Performance Regression
- Example 62: Delta-Debugging a 10,000-Line Crash
Native & Systems (Examples 63–80)
- Example 63: gdb Attach to CPython -- a Real Limitation
- Example 64: py-locals/py-print -- Still Gated by gdb
- Example 65: lldb with cpython_lldb -- a Real Limitation
- Example 66: lldb Post-Mortem -- a Real Substitute
- Example 67: perf record with Python perf Support
- Example 68: perf script to flamegraph.pl
- Example 69: perf script to inferno
- Example 70: Native Cost Hidden from cProfile
- Example 71: py-spy --native -- a Real Limitation
- Example 72: One Repo, Two Bugs -- Correctness and Performance
- Example 73: The Recursive tottime vs. cumtime Trap
- Example 74: A Cache That Never Evicts
- Example 75: Import-Time Startup Profiling
- Example 76: Lock Contention Under Load
- Example 77: A Flame-Graph Diff, Before/After
- Example 78: Deterministic Seeding for a Flaky Bug
- Example 79: Bisecting with a Flaky-Test Guard
- Example 80: A Low-Overhead Tracer with sys.monitoring
← Previous: Overview · Next: Beginner Examples →
Last updated July 14, 2026