3 min

From notebook to production without a rewrite

Notebooks are where analysis is born, not where it should live. A low-drama path: extract functions, add tests for data logic, parameterise, and schedule a script.

On this page 5 sections
  1. Step 1: make it run top-to-bottom
  2. Step 2: extract functions, leave the story
  3. Step 3: test the data logic, skip the ML theater
  4. Step 4: parameterise and schedule the script, not the notebook
  5. The workflow that emerges

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:

pipeline.py
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 inspection
from pipeline import load_orders, clean_orders, build_features, train
df = clean_orders(load_orders(conn, since="2026-01-01"))
df.amount.hist() # charts and eyeballing stay here, where they belong

The 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"] == 2

That 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:

run.py
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.