Debugging and the hidden-state trap
Post-mortem debugging, breakpoints, logging, and the techniques that expose a notebook whose results depend on cells you already deleted.
Debugging inside the kernel
%pdb on # drop into the debugger at any uncaught exception
# after a crash, without %pdb:
%debug # inspect the traceback frame by frame
def parse(rows):
breakpoint() # Python 3.7+: enters pdb right here
return [r for r in rows if r["ok"]](Pdb) l list source around the current line
(Pdb) p rows[0] print an expression
(Pdb) w show the call stack
(Pdb) u / d move up / down the stack
(Pdb) q quit the debugger and the cell%debugworks on the most recent exception, even after the cell finished, as long as the kernel has not restarted.breakpoint()respects thePYTHONBREAKPOINTenvironment variable, so you can disable it in production.- Variable edits you make in the debugger persist in the kernel afterwards, which is useful and also dangerous.
Detecting stale state
| Symptom | Likely cause |
|---|---|
NameError after a restart | A cell you deleted or never ran defined that name |
| Results change with no code change | Randomness, or a variable reassigned in an old cell |
| Fix works locally, fails for others | The notebook was never run top-to-bottom |
| Imported module seems not to update | Cached import; use %autoreload 2 or restart |
# list everything the kernel currently holds
%who DataFrame
%whos
# prove a variable was never defined by the code you can see
import inspect
print(inspect.getsource(parse))💡
The only reliable test of a notebook is Restart Kernel and Run All, followed by
nbconvert --execute for the version you share. Hidden state is not a bug you fix once; it is a habit you keep.Structure for testability
import logging
from pathlib import Path
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
def build_features(df):
"""Pure function: no prints, no globals, easy to unit-test."""
return df.assign(ratio=df["a"] / df["b"])
# in the notebook, just orchestrate
df = read_csv(Path("data/raw/sales.csv"))
features = build_features(df)
logging.info("rows=%d cols=%d", *features.shape)Logging beats printing: messages carry timestamps and levels, survive %%capture handling, and can be redirected to a file when the notebook runs as a batch job.
FAQ
Why does the same cell give different results each run?
Almost always randomness or hidden state. Seed your generators, then restart the kernel and run all cells. If the difference persists, print the inputs and check for a variable reassigned earlier in the session.
How do I debug a cell that takes ten minutes to reach the failure?
Isolate the slow part behind a cached artefact: save the intermediate DataFrame to disk in the earlier cell, then load it while iterating on the failing cell. Restart only when you need to verify the whole chain.
Related
Notebook fundamentals Magics: line, cell and shell commands
Last refreshed 2026-09-18.