The notebook is a superb thinking tool and a terrible production artifact. The
qualities that make it great for exploration — mutable global state, run-cells-
in-any-order, output next to code — are exactly what makes it untrustworthy
unattended. Cell 12 works because you ran cell 30 last Tuesday; the variable
df2 survives from an experiment you deleted. When a notebook becomes a
scheduled job by way of “we’ll just run it every morning,” it becomes the most
fragile service in the company.
The good news: the path out is not a rewrite. It’s a gradual extraction.
Step 1: make it run top-to-bottom
Before anything else, restart the kernel and run all. Fix whatever breaks.
A notebook that can’t execute cleanly from a cold start isn’t code yet — it’s
a transcript of a session. This single discipline (enforceable in CI with
jupyter nbconvert --execute or papermill) kills the largest class of
notebook rot.
Step 2: extract functions, leave the story
Move logic into plain functions in a module next to the notebook; let the notebook become the narrative that calls them:
def load_orders(conn, since: str) -> pd.DataFrame: ...def clean_orders(df: pd.DataFrame) -> pd.DataFrame: ...def build_features(df: pd.DataFrame) -> pd.DataFrame: ...def train(features: pd.DataFrame, seed: int = 42) -> Model: ...# notebook cell — now just orchestration and inspectionfrom pipeline import load_orders, clean_orders, build_features, traindf = clean_orders(load_orders(conn, since="2026-01-01"))df.amount.hist() # charts and eyeballing stay here, where they belongThe rule of thumb: logic in the module, looking in the notebook. With
%autoreload
enabled, iteration speed doesn’t suffer — you keep the
interactive loop while the substance accumulates somewhere testable and
diffable (notebook diffs in git are famously unreadable; module diffs are
normal code review).
Step 3: test the data logic, skip the ML theater
You don’t need 90% coverage. You need pytest tests for the functions where silent wrongness lives — the cleaning and feature logic:
def test_clean_orders_drops_duplicates(): raw = pd.DataFrame({"order_id": [1, 1, 2], "amount": [10, 10, 99]}) assert clean_orders(raw).order_id.is_unique
def test_features_asof_ignores_future_orders(): feats = build_features(orders, asof="2026-03-01") # an order on 2026-03-05 must not appear in any feature assert feats.loc["cust_7", "orders_30d"] == 2That second test is the most valuable kind you can write in this field: it pins down point-in-time correctness, the property whose violation produces great backtests and dead production models. Five such tests outperform a hundred trivial ones.
Step 4: parameterise and schedule the script, not the notebook
Hardcoded dates and paths become arguments; the entry point becomes a script the scheduler can run and — critically — that can fail loudly:
if __name__ == "__main__": args = parse_args() # --since, --output, --seed df = clean_orders(load_orders(get_conn(), args.since)) validate(df) # halt on schema surprises result = train(build_features(df), seed=args.seed) save(result, args.output)Exit codes, logs, and alerts on failure — the boring machinery notebooks don’t have. If the outputs are reports rather than models, papermill-executing a parameterised notebook is a legitimate middle ground; just treat its failures with the same seriousness as a service’s.
The workflow that emerges
Notebooks stay in the loop forever — as the front porch: explore freely, and each time an experiment graduates to “we rely on this,” extract its logic into the module, add a test for its sharpest edge, and thin the notebook back down to narrative. The refactor stops being a dreaded phase-two project and becomes a steady metabolism. If none of this exists yet, the first move is small: open the notebook you re-run most often, find the one function everything downstream depends on, and move that function — alone — into a module with a test beside it. Leave everything else where it is. The second extraction takes half the time, and by the tenth nobody plans them any more.