What Notebooks Are Good At
Jupyter notebooks combine executable code, rich output, and narrative prose in a single document. This makes them ideal for exploratory data analysis, teaching, and communicating results to stakeholders. A well-written notebook tells a story: it explains why each step is taken, not merely what is computed.
The notebook file format (*.ipynb) is JSON under the hood. Each cell has a type (code, markdown, or raw), source text, and a list of outputs. The kernel maintains a live Python (or R, Julia, …) process; executing a cell sends its source to the kernel and captures stdout, stderr, rich MIME outputs (images, HTML, LaTeX), and the execution count.
The Hidden-State Problem
The single greatest reproducibility hazard in notebooks is out-of-order execution. The kernel maintains a global namespace. If you run cell 5 before cell 3, then edit cell 3, then re-run cell 5, the results depend on execution history that is not visible in the document. A collaborator who opens the notebook and runs all cells top-to-bottom may get completely different results.
The canonical failure looks like this:
# Cell 1 — run first
x = 10
# Cell 3 — accidentally run before cell 2
result = x * multiplier # Works only if multiplier exists from a previous session
# Cell 2 — run after cell 3
multiplier = 5When you share this notebook, the recipient who runs it top-to-bottom gets a NameError. You never saw that error because your kernel already had multiplier defined from an earlier run.
Rule 1: Restart & Run All before committing or sharing a notebook. Kernel → Restart & Run All should complete without errors. This is non-negotiable for any notebook that leaves your machine.
Cell Organization and Notebook Hygiene
Keep notebooks linear
Organize cells so execution flows strictly from top to bottom. If you find yourself needing to re-run an earlier cell after running later ones, that is a design smell — factor the dependency into a shared function.
One notebook, one narrative
A notebook should answer one question or tell one story. If it exceeds roughly 400 lines of code, break it apart. Extract reusable logic into .py modules in a src/ directory and import them; this makes code testable and keeps notebooks focused on narrative.
# src/features.py — plain Python module, version-controlled, unit-testable
def compute_rolling_average(series, window=7):
"""Return the rolling mean with min_periods=1."""
return series.rolling(window, min_periods=1).mean()# In the notebook
import importlib, sys
sys.path.insert(0, "../src") # or install the package in editable mode
import features
import importlib; importlib.reload(features) # during active development
df["rolling_avg"] = features.compute_rolling_average(df["value"])Use %autoreload during development
%load_ext autoreload
%autoreload 2 # reload all modules before each cell executionThis prevents the stale-import trap where you edit a .py file but the notebook still uses the old version.
Markdown cells are first-class
Every significant block of computation should be preceded by a markdown cell explaining what it does and why. This is the essence of literate programming: the notebook is a document that happens to contain executable code, not a script with comments.
Inline math uses single dollars: $\mu = \frac{1}{n}\sum_{i=1}^n x_i$.
Block math uses double dollars on their own line:
Outputs and Version Control
Notebook outputs (plots, tables, print statements) are stored in the JSON file. This creates two problems for version control:
- Diff noise: a single rerun changes dozens of JSON lines even if the code is identical.
- Binary bleed: embedded base64-encoded PNG images bloat the repository.
The solution is to strip outputs before committing using nbstripout:
pip install nbstripout
nbstripout --install # installs a git filter for *.ipynb in this repoAfter this one-time setup, git diff and git add operate on output-free notebooks. Outputs are regenerated on demand by running the notebook.