An analysis somebody else can run is one where the environment, the randomness, the execution order and the inputs are all pinned. Four things break reproducibility and each has a short fix.
1. The environment is not written down
import sys
import platform
# Print it IN THE OUTPUT, not only in a file nobody reads.
try:
import numpy as np
import pandas as pd
versions = {
'python': platform.python_version(),
'numpy': np.__version__,
'pandas': pd.__version__,
}
except ImportError:
versions = {'python': platform.python_version()}
for name, version in versions.items():
print(f'{name:<8} {version}')
Open this example in the editor and change it. Nothing you type is saved.
Then pin them, so the next person gets the same ones:
# Shell commands, shown as text.
#
# python -m venv .venv && source .venv/bin/activate
# pip install numpy pandas matplotlib scikit-learn
# pip freeze > requirements.txt
#
# And to reproduce:
#
# pip install -r requirements.txt
#
# `pip freeze` pins transitive dependencies too, which is what makes
# it reproducible rather than merely documented.
print('pin everything, including the dependencies of dependencies')
Open this example in the editor and change it. Nothing you type is saved.
A requirements.txt with pandas in it is not a pin. pandas==2.2.3 is.
2. Nothing fixed the randomness
import numpy as np
# Not reproducible: a different sample every run.
a = np.random.default_rng().integers(0, 100, 5)
b = np.random.default_rng().integers(0, 100, 5)
print('unseeded differ:', not np.array_equal(a, b))
# Reproducible: the same sample every run, forever.
c = np.random.default_rng(seed=42).integers(0, 100, 5)
d = np.random.default_rng(seed=42).integers(0, 100, 5)
print('seeded agree: ', np.array_equal(c, d))
print(c)
Open this example in the editor and change it. Nothing you type is saved.
Three sources of randomness need seeding independently, and missing one is enough:
import random
import numpy as np
SEED = 42
random.seed(SEED) # the standard library
rng = np.random.default_rng(SEED) # NumPy, and pass this around
# torch.manual_seed(SEED) # if you use PyTorch
# and scikit-learn takes random_state= on nearly everything
print(random.randint(0, 100))
print(rng.integers(0, 100))
Open this example in the editor and change it. Nothing you type is saved.
Prefer a Generator you pass explicitly over the global np.random.seed. A global seed is modified by any library that touches np.random, so a result that depends on it depends on import order.
import numpy as np
from itertools import islice
def sample(rng, values, k):
"""Takes the generator as an argument - so the caller controls the
randomness and the function is testable."""
return rng.choice(values, size=k, replace=False)
values = np.arange(20)
print(sample(np.random.default_rng(7), values, 5))
print(sample(np.random.default_rng(7), values, 5)) # identical
Open this example in the editor and change it. Nothing you type is saved.
Pass random_state= to every scikit-learn call that takes it: train_test_split, KFold, RandomForestClassifier, KMeans. Each has its own, and each unseeded one is a result that will not reproduce.
3. The notebook has hidden state
# This sequence leaves no trace in the saved file:
#
# In [1]: df = load() # 10,000 rows
# In [2]: df = df[df.region == 'North'] # now 2,000
# In [1]: df = load() # re-run: back to 10,000
# In [3]: result = analyse(df) # on 10,000, not 2,000
#
# The output under cell 2 still says 2,000. Nothing says the result
# below came from different data.
print('restart and run all, before believing any number')
Open this example in the editor and change it. Nothing you type is saved.
Three habits:
- Restart and run all before you trust a result, and always before you share one.
- Keep the cells in execution order, so restart-and-run-all does what you did.
- Move anything reusable into a
.pymodule and import it, so the notebook is a thin layer over code you can test.
# A notebook cell that imports its own project code, with autoreload
# so edits to the module take effect without a restart:
#
# %load_ext autoreload
# %autoreload 2
# from analysis.clean import clean_orders
# from analysis.report import monthly_summary
#
# The notebook then holds the narrative and the charts; the module
# holds the logic, under test.
print('notebook: narrative. module: logic.')
Open this example in the editor and change it. Nothing you type is saved.
4. The inputs can change underneath
import hashlib
import io
RAW = chr(10).join(['order_id,region,units', '1,North,10', '2,South,20', ''])
digest = hashlib.sha256(RAW.encode('utf-8')).hexdigest()
print('sha256', digest[:16])
# Record it with the result. If the number changes next month, the
# first question - "is it the same input?" - has an answer.
manifest = {
'source': 'orders-2026-03.csv',
'sha256': digest,
'rows': RAW.count(chr(10)) - 1,
'retrieved': '2026-04-01T09:00:00Z',
}
for key, value in manifest.items():
print(f'{key:<10} {value}')
Open this example in the editor and change it. Nothing you type is saved.
"The sales table" is not an input; a snapshot of it at a known time is. A query against a live database gives a different answer every day, which means last month's chart cannot be regenerated. Save the extract, record its hash, and read the file.
Asserts are documentation that runs
import numpy as np
import pandas as pd
rng = np.random.default_rng(3)
orders = pd.DataFrame({
'order_id': np.arange(1, 101),
'region': rng.choice(['North', 'South'], 100),
'revenue': rng.normal(500, 100, 100).round(2),
})
# What the next step assumes, stated where it is assumed.
assert orders['order_id'].is_unique, 'order_id must be unique'
assert orders['revenue'].notna().all(), 'revenue has missing values'
assert (orders['revenue'] > 0).all(), 'revenue must be positive'
assert set(orders['region']) <= {'North', 'South', 'East', 'West'}, 'unexpected region'
assert len(orders) == 100, f'expected 100 rows, got {len(orders)}'
print('all assumptions hold for', len(orders), 'rows')
Open this example in the editor and change it. Nothing you type is saved.
An assertion is better than a comment because it is checked, and better than a test because it runs against the real data. Put them at every boundary: after a load, after a merge, before a result is written.
The merge assertion in particular:
import pandas as pd
left = pd.DataFrame({'k': [1, 2, 3], 'v': [10, 20, 30]})
right = pd.DataFrame({'k': [1, 2, 3], 'w': ['a', 'b', 'c']})
before = len(left)
joined = left.merge(right, on='k', how='left', validate='one_to_one')
assert len(joined) == before, f'{before} rows became {len(joined)}'
print('row count held at', len(joined))
Open this example in the editor and change it. Nothing you type is saved.
Write down the decisions
# Every analysis makes judgement calls. The ones that change the
# answer belong in the output, not in somebody's memory.
decisions = {
'period': '2026-01-01 to 2026-03-31 inclusive',
'excluded': 'cancelled and test orders',
'currency': 'GBP, converted at the rate on the order date',
'missing units': 'imputed with the median, flagged in units_imputed',
'outliers': 'kept - the long tail is real, not an error',
'timezone': 'UTC throughout, converted for display only',
}
width = max(len(k) for k in decisions)
for name, choice in decisions.items():
print(f'{name:<{width}} {choice}')
Open this example in the editor and change it. Nothing you type is saved.
Six lines, and a reader who gets a different number knows immediately which assumption to check.
A worked example
import hashlib
import io
import platform
import random
import sys
import numpy as np
import pandas as pd
SEED = 20260407
# ---- 1. Provenance, printed with the result. -----------------------
print('environment')
print(f' python {platform.python_version()} numpy {np.__version__} '
f'pandas {pd.__version__}')
random.seed(SEED)
rng = np.random.default_rng(SEED)
print(f' seed {SEED}')
print()
# ---- 2. A snapshot, hashed. ----------------------------------------
RAW = """order_id,placed_on,region,units,price,status
1001,2026-01-15,North,120,12.50,complete
1002,2026-01-16,South,95,12.50,complete
1003,2026-01-16,North,,11.00,pending
1004,2026-02-01,East,60,14.00,cancelled
1005,2026-02-03,West,45,13.25,complete
1006,2026-02-14,North,143,11.00,complete
1007,2026-03-02,South,72,12.00,complete
1008,2026-03-20,East,88,13.50,test
"""
digest = hashlib.sha256(RAW.encode('utf-8')).hexdigest()
print('input')
print(f' sha256 {digest[:32]}')
print(f' bytes {len(RAW)}')
print()
# ---- 3. Load, with every dtype named. ------------------------------
orders = pd.read_csv(
io.StringIO(RAW),
dtype={'order_id': 'int64', 'region': 'category', 'status': 'category'},
parse_dates=['placed_on'],
)
assert len(orders) == 8, f'expected 8 rows, got {len(orders)}'
assert orders['order_id'].is_unique, 'order_id is not unique'
assert orders['placed_on'].notna().all(), 'a date failed to parse'
# ---- 4. Decisions, stated. -----------------------------------------
DECISIONS = {
'period': '2026-01-01 to 2026-03-31',
'excluded statuses': 'cancelled, test',
'missing units': 'median of the included rows, flagged',
'currency': 'GBP, no conversion needed',
}
print('decisions')
for name, choice in DECISIONS.items():
print(f' {name:<18} {choice}')
print()
# ---- 5. The analysis, with its assumptions checked as it goes. -----
EXCLUDED = {'cancelled', 'test'}
included = orders[~orders['status'].isin(EXCLUDED)].copy()
print(f'{len(included)} of {len(orders)} rows included '
f'({len(orders) - len(included)} excluded by status)')
included['units_imputed'] = included['units'].isna()
median_units = round(float(included['units'].median()))
included['units'] = included['units'].fillna(median_units).astype('int64')
print(f'{int(included["units_imputed"].sum())} row(s) imputed with {median_units} units')
included['revenue'] = (included['units'] * included['price']).round(2)
assert (included['revenue'] > 0).all(), 'a revenue is not positive'
period = (included['placed_on'].min(), included['placed_on'].max())
assert period[0] >= pd.Timestamp('2026-01-01'), 'a row predates the period'
assert period[1] <= pd.Timestamp('2026-03-31'), 'a row postdates the period'
print()
# ---- 6. The result, with the manifest attached. --------------------
summary = (included.groupby('region', as_index=False, observed=True)
.agg(orders=('order_id', 'size'), revenue=('revenue', 'sum'))
.sort_values('revenue', ascending=False)
.round(2))
print(summary.to_string(index=False))
print()
total = float(included['revenue'].sum())
assert abs(summary['revenue'].sum() - total) < 0.01, 'the groups do not sum to the total'
print(f'total {total:,.2f} across {len(included)} orders')
print()
manifest = {
'seed': SEED,
'input_sha256': digest[:16],
'rows_in': len(orders),
'rows_used': len(included),
'imputed': int(included['units_imputed'].sum()),
'total_revenue': round(total, 2),
'python': platform.python_version(),
'pandas': pd.__version__,
}
print('manifest')
for key, value in manifest.items():
print(f' {key:<14} {value}')
print()
# ---- 7. Prove it reproduces, in the same file. ---------------------
def run(seed):
local_rng = np.random.default_rng(seed)
sample = local_rng.choice(included.index.to_numpy(), size=3, replace=False)
return included.loc[sorted(sample), 'revenue'].tolist()
print('same seed, same sample:', run(SEED) == run(SEED))
print('different seed, different sample:', run(SEED) != run(SEED + 1))
Open this example in the editor and change it. Nothing you type is saved.
Seven things make that reproducible rather than merely tidy. The versions and the seed are printed. The input is hashed. Every dtype is named at load time. The judgement calls are listed. Five assertions check what the next step assumes. The manifest ties the result to the input that produced it. And the last two lines test the reproducibility claim rather than asserting it in prose.
When it goes wrong
| Symptom | Cause |
|---|---|
| The same notebook gives different numbers | No seed, or cells run out of order |
| A colleague gets a different answer | Unpinned versions; pip freeze |
| Last month's chart cannot be regenerated | A live query, not a snapshot |
| A seed had no effect | A Generator was used; seed that, or the library's own |
| scikit-learn results vary between runs | A missing random_state= |
| A result changed and nobody knows why | No input hash to compare |
| A merge silently changed the total | No validate= and no row-count assertion |
| The code works only in one person's notebook | Hidden state; restart and run all |
A check you can run
Restart the kernel and run every cell of your current notebook from the top.
If any number, chart or error differs from what was on screen, you have hidden state and every conclusion below that point is unverified. It takes under a minute, and in my experience about one notebook in three fails it the first time.