Notebook, App or Report: Choosing How to Ship an Analysis
On this page
Problem statement
The analysis is done. How it is delivered decides whether it is used, and the choice is usually made by habit: the analyst ships a notebook because that is where the work happened, or builds an app because somebody said "dashboard".
The four options have genuinely different properties:
- A report β a PDF or a document. No runtime, no dependencies, archivable, and fixed.
- A notebook β the work itself. Reproducible for people with the environment; opaque to everybody else.
- An app β interactive, and a running service with an owner, a URL and an uptime expectation.
- A dataset β the numbers, published. No interpretation, maximum reuse.
The cost difference is not marginal. A report is finished when it is written. An app has a deployment, an environment that must keep resolving, a per-interaction cost β 13 to 17 ms a rerun for the small app on this page, 0.81 s for a map app that rebuilds its layer on every change β and somebody who has to maintain it in a year.
Quick answer
def deliverable(*, audience, questions, data_changes, reproducibility_matters,
audience_has_python):
if reproducibility_matters and audience_has_python:
return "a notebook, plus the data β the work is the deliverable"
if questions == 1 and not data_changes:
return "a report β a PDF with the figure and the finding"
if questions < 6 and not data_changes:
return "a report with small multiples β one figure per case"
if data_changes and audience > 5:
return "an app, plus a published dataset"
return "a report, and publish the data alongside it"
print(deliverable(audience=40, questions=1, data_changes=False,
reproducibility_matters=False, audience_has_python=False))
print(deliverable(audience=40, questions=5, data_changes=False,
reproducibility_matters=False, audience_has_python=False))
print(deliverable(audience=40, questions=20, data_changes=True,
reproducibility_matters=False, audience_has_python=False))
a report β a PDF with the figure and the finding
a report with small multiples β one figure per case
an app, plus a published dataset
Publishing the data is on almost every line, because it is cheap, it satisfies the request behind many app requests, and it is the only deliverable that outlives the tooling.
Audience, questions, full cost and reproducibility
1. Ask what the audience will do with it
- Read a conclusion β a report.
- Check the method β a notebook, or a report with the code appended.
- Ask their own questions β an app, or a dataset plus a notebook.
- Feed it into their own work β a dataset, in a format their tools open.
Most requests for a dashboard are one of the last two wearing different clothes. Asking directly saves weeks.
2. Count the questions, honestly
One question is a figure. Half a dozen fixed cases are small multiples in a report β and small multiples are frequently better than an app, because the reader sees all the cases at once instead of clicking through them.
An app earns its cost when the number of combinations is large enough that no set of figures covers them, and when the reader has to choose.
3. Account for the full cost of each option
| report | notebook | app | dataset | |
|---|---|---|---|---|
| production | hours | hours | daysβweeks | hours |
| runtime | none | reader's | yours | none |
| dependencies at view time | none | many | yours | none |
| uptime expectation | none | none | yes | storage only |
| ages | gracefully | badly | badly | gracefully |
| needs an owner | no | no | yes | lightly |
The last two rows are what people underestimate. A notebook whose environment no longer resolves is unusable, and an app nobody maintains is a URL that eventually 502s.
4. Decide how reproducibility will be demonstrated
A report says what was found. It does not say how, and "how" is what a reviewer needs.
The pairings that work:
- report + published dataset β the reader can check the numbers.
- report + notebook β the reader can check the method.
- app + dataset + method note β the reader can explore and verify.
An app alone is the weakest for reproducibility: the reader sees outputs and cannot see the derivation.
5. Publish the data, whatever else you do
It is the cheapest deliverable on the list and the most durable. It answers "can you send me the numbers?", it lets somebody re-classify or re-project, and it survives the tooling that produced it.
A GeoPackage or a GeoParquet file on a URL, with a README naming the source, the date and the CRS, costs an hour.
6. Set an expiry on anything with a runtime
An app or a notebook that nobody has opened in six months is a liability: dependencies drift, secrets expire, and the environment stops building.
Deciding at the start how long a deliverable is expected to live β and writing it in the README β makes decommissioning a plan rather than an outage.
One analysis as three deliverables
The data used on this page
The "districts" are Europe's NUTS 3 regions β 1,333 of them once joined to data β from Eurostat GISCO at 1:1 million, with GISCO's urbanβrural typology as the category, and the share of the population aged 65 and over for 2019 to 2023 from the Eurostat table demo_r_pjanind3 as rate_2019 β¦ rate_2023. Eurostat refuses the default python-requests user agent, hence the header.
import math
import os
import geopandas as gpd
import pandas as pd
import requests
YEARS = [2019, 2020, 2021, 2022, 2023]
def eurostat(table, **filters):
"""A Eurostat table as a Series indexed by every dimension."""
stat = requests.get(f"https://ec.europa.eu/eurostat/api/dissemination/statistics/1.0/data/{table}",
params={"format": "JSON", **filters},
headers={"User-Agent": "spatialworkflow.io guide"}, timeout=120).json()
codes = [sorted(stat["dimension"][d]["category"]["index"], key=stat["dimension"][d]["category"]["index"].get)
for d in stat["id"]]
values = [stat["value"].get(str(i)) for i in range(math.prod(stat["size"]))]
return pd.Series(values, index=pd.MultiIndex.from_product(codes, names=stat["id"]), dtype=float)
if not os.path.exists("districts.gpkg"):
nuts = gpd.read_file("https://gisco-services.ec.europa.eu/distribution/v2/nuts/geojson/"
"NUTS_RG_01M_2021_4326_LEVL_3.geojson")
older = (eurostat("demo_r_pjanind3", indic_de="PC_Y65_MAX", unit="PC",
sinceTimePeriod=YEARS[0], untilTimePeriod=YEARS[-1])
.droplevel(["freq", "indic_de", "unit"]).unstack("time"))
older.columns = [f"rate_{year}" for year in older.columns]
kind = {1: "urban", 2: "intermediate", 3: "rural"}
(nuts.assign(category=nuts["URBN_TYPE"].map(kind))
.rename(columns={"NUTS_ID": "code", "CNTR_CODE": "country", "NAME_LATN": "name"})
[["code", "country", "name", "category", "geometry"]]
.merge(older, left_on="code", right_index=True)
.dropna(subset=[f"rate_{YEARS[-1]}"])
.to_file("districts.gpkg"))
districts = gpd.read_file("districts.gpkg")
print(f"{len(districts):,} NUTS 3 regions in {districts.country.nunique()} countries; "
f"share aged 65+ in {YEARS[-1]}: {districts[f'rate_{YEARS[-1]}'].min():.1f}β"
f"{districts[f'rate_{YEARS[-1]}'].max():.1f}%")
print(districts.category.value_counts().to_dict(),
f"{districts.name.nunique():,} distinct names")
1,333 NUTS 3 regions in 36 countries; share aged 65+ in 2023: 2.7β36.0%
{'intermediate': 619, 'rural': 438, 'urban': 276} 1,332 distinct names
Example 1 β the same analysis, three deliverables
%%writefile deliverables.py
"""One analysis module; three thin shells around it."""
import os
import geopandas as gpd
YEARS = [2019, 2020, 2021, 2022, 2023]
def analyse(districts: gpd.GeoDataFrame, year: int, threshold: float) -> dict:
column = f"rate_{year}"
above = districts[districts[column] > threshold]
return {"year": year, "threshold": threshold,
"districts": len(districts), "above": len(above),
"median": float(districts[column].median()),
"worst": above.nlargest(5, column)["name"].tolist()}
# 1. report: render a figure and a paragraph, once
def report(districts, year=2023, threshold=25.0, out="report.pdf"):
import matplotlib.pyplot as plt
findings = analyse(districts, year, threshold)
fig, ax = plt.subplots(figsize=(170 / 25.4, 120 / 25.4))
districts.plot(column=f"rate_{year}", cmap="YlGnBu", legend=True, ax=ax)
ax.set_axis_off()
ax.set_title(f"{findings['above']} of {findings['districts']} districts "
f"above {threshold}% in {year}", loc="left", fontsize=9)
fig.savefig(out)
return out, findings
# 2. dataset: publish the numbers
def dataset(districts, out="published/districts.gpkg"):
os.makedirs(os.path.dirname(out), exist_ok=True)
districts.to_file(out, driver="GPKG", layer="districts")
with open(os.path.join(os.path.dirname(out), "README.md"), "w") as handle:
handle.write("# NUTS 3 regions: share of population aged 65+\n\n"
"Source: Eurostat demo_r_pjanind3, GISCO NUTS 2021 1:1M Β· "
f"CRS: {districts.crs.to_string()} Β· Years: {YEARS[0]}β{YEARS[-1]}\n")
return out
# 3. app: the same function, behind widgets
def app(districts):
import streamlit as st
year = st.selectbox("Year", YEARS, index=len(YEARS) - 1, key="year")
threshold = st.slider("Threshold (%)", 0.0, 40.0, 25.0, key="threshold")
findings = analyse(districts, year, threshold)
st.metric("Above threshold", findings["above"])
st.write(findings["worst"])
All three, run on the same regions:
%%writefile app.py
import geopandas as gpd
import streamlit as st
from deliverables import app
@st.cache_data
def load(path):
return gpd.read_file(path)
app(load("districts.gpkg"))
import time
from streamlit.testing.v1 import AppTest
from deliverables import analyse, dataset, report
path, findings = report(districts)
print(f"report {path}, {os.path.getsize(path) / 1e3:.0f} kB: {findings['above']} of "
f"{findings['districts']} regions above 25% in 2023, highest {findings['worst'][:3]}")
out = dataset(districts)
print(f"dataset {out}, {os.path.getsize(out) / 1e6:.1f} MB, with published/README.md")
at = AppTest.from_file("app.py", default_timeout=120).run()
print(f"app first run: {at.metric[0].value} above the threshold")
times = []
for year in (2019, 2020, 2021):
at.selectbox(key="year").set_value(year)
started = time.perf_counter()
at.run()
times.append(time.perf_counter() - started)
print(f" reruns {', '.join(f'{t * 1000:.0f} ms' for t in times)}; in 2021, {at.metric[0].value} above")
print(" same answer as the report's function:", analyse(districts, 2021, 25.0)["above"])
report report.pdf, 5160 kB: 282 of 1333 regions above 25% in 2023, highest ['Alto TΓ’mega', 'Arr. Veurne', 'Evrytania']
dataset published/districts.gpkg, 11.4 MB, with published/README.md
app first run: 282 above the threshold
reruns 15 ms, 17 ms, 13 ms; in 2021, 241 above
same answer as the report's function: 241
Keeping analyse separate is what makes all three cheap. The deliverable becomes a choice rather than a rewrite, and the analysis is testable without any of them.
Example 2 β small multiples instead of an app
import matplotlib.pyplot as plt
def small_multiples(districts, years, column_template="rate_{year}",
bins=None, out="years.pdf"):
"""Fixed cases the reader sees at once, rather than clicks through."""
import math
import mapclassify
values = districts[[column_template.format(year=y) for y in years]]
# pandas 3's stack() keeps NaN; a NaN in the quantiles makes a NaN break
bins = bins or list(mapclassify.Quantiles(values.stack().dropna(), k=5).bins)
columns = 3
rows = math.ceil(len(years) / columns)
fig, axes = plt.subplots(rows, columns,
figsize=(170 / 25.4, 60 * rows / 25.4))
for ax, year in zip(axes.ravel(), years):
districts.plot(column=column_template.format(year=year),
scheme="UserDefined", classification_kwds={"bins": bins},
cmap="YlGnBu", ax=ax, legend=False,
edgecolor="white", linewidth=0.3,
missing_kwds={"color": "#d4d4d4"}) # a year with no value
ax.set_title(str(year), fontsize=8, loc="left")
ax.set_axis_off()
for ax in axes.ravel()[len(years):]:
ax.set_visible(False)
fig.savefig(out, bbox_inches=None)
return out, bins
from deliverables import YEARS
out, bins = small_multiples(districts, YEARS)
print(f"{out}: {len(YEARS)} panels on one page, shared breaks {[round(float(b), 1) for b in bins]}")
print("regions with no value, by year:",
{year: int(districts[f"rate_{year}"].isna().sum()) for year in YEARS})
years.pdf: 5 panels on one page, shared breaks [18.4, 20.7, 22.4, 24.7, 36.0]
regions with no value, by year: {2019: 10, 2020: 2, 2021: 0, 2022: 12, 2023: 0}
Two lines are there because real data has gaps: some regions have no value for some years. The first version of this function computed the breaks from values.stack(), which under pandas 3 keeps the gaps, so the top quantile came out as NaN and every panel stopped with Categorical categories cannot be null; .dropna() fixes the breaks, and missing_kwds draws the regions without a value in grey instead of leaving holes. Panels with shared class breaks answer "how has this changed?" better than an app with a year slider, because the comparison is on one page rather than in the reader's memory.
Example 3 β a deliverable manifest
from dataclasses import dataclass, field
import datetime
@dataclass
class Deliverable:
kind: str # report | notebook | app | dataset
path_or_url: str
audience: str
owner: str
review_by: datetime.date
inputs: list = field(default_factory=list)
notes: str = ""
def to_markdown(self) -> str:
return (f"- **{self.kind}** β [{self.path_or_url}]({self.path_or_url})\n"
f" - audience: {self.audience}\n"
f" - owner: {self.owner}\n"
f" - review by: {self.review_by.isoformat()}\n"
f" - inputs: {', '.join(self.inputs) or 'β'}\n"
+ (f" - notes: {self.notes}\n" if self.notes else ""))
manifest = [
Deliverable("report", "report.pdf", "regional planners", "analysis team",
datetime.date(2027, 9, 29), ["Eurostat demo_r_pjanind3"]),
Deliverable("dataset", "published/districts.gpkg", "anyone", "analysis team",
datetime.date(2027, 9, 29), ["Eurostat demo_r_pjanind3", "GISCO NUTS 2021"]),
Deliverable("app", "app.py", "regional planners", "analysis team",
datetime.date(2027, 3, 29), ["published/districts.gpkg"],
notes="retire if unused by the review date"),
]
print("".join(item.to_markdown() for item in manifest))
- **report** β [report.pdf](report.pdf)
- audience: regional planners
- owner: analysis team
- review by: 2027-09-29
- inputs: Eurostat demo_r_pjanind3
- **dataset** β [published/districts.gpkg](published/districts.gpkg)
- audience: anyone
- owner: analysis team
- review by: 2027-09-29
- inputs: Eurostat demo_r_pjanind3, GISCO NUTS 2021
- **app** β [app.py](app.py)
- audience: regional planners
- owner: analysis team
- review by: 2027-03-29
- inputs: published/districts.gpkg
- notes: retire if unused by the review date
Writing down the owner and a review date for anything with a runtime is what turns an app from a permanent liability into a thing with a lifecycle.
Reports underrated, apps costly to keep
Why a report is underrated
It has no runtime and no dependencies at view time. It can be emailed, printed, filed and read in five years by somebody with no Python.
The objection is that it is fixed β and that is the point. A finding worth communicating is usually one finding, and a document says it once, clearly, in a form that cannot break.
Why notebooks are for peers, not for stakeholders
A notebook is the work: inputs, method, intermediate results and conclusions in order. For somebody who can run it, that is the most useful artefact possible.
For somebody who cannot, it is a wall of code with the answer hidden in the middle. And it ages badly β a notebook whose environment no longer resolves is unusable, which is why it should always be paired with the data it produced.
Why an app's real cost is ongoing
Production is a fraction of it. An app has a URL people bookmark, a Python environment that must keep resolving, a deployment that must keep deploying, and a per-interaction cost paid on your infrastructure.
Measured, an interaction cost 13 to 17 ms in this page's small app and 0.81 s in a map app that rebuilds a 13 MB layer, and that cost is paid per user per click. None of that appears in the estimate somebody gives when they say "just make it interactive".
Why publishing the data belongs in every answer
It costs an hour, it satisfies most of the requests that arrive disguised as dashboard requests, and it is the only deliverable that survives the tooling.
It also changes the relationship: an analysis that ships its data invites checking, and one that does not asks to be trusted. For anything that matters, the first is the better position to be in.
Small multiples, pinned notebooks and app owners
- Small multiples often beat an app for a handful of fixed cases β the comparison is on one page.
- A notebook needs its environment pinned, or it is unrunnable within a year.
- An app needs an owner and a review date, or it becomes a URL that eventually 502s.
- A PDF is the most portable deliverable and the least reusable.
- Publish the data in a format the audience opens β GeoPackage for GIS, CSV for spreadsheets, Parquet for Python.
- Keep the analysis in a module, so the deliverable is a choice rather than a rewrite.
- Ask what the audience will do with it, not what they asked for.
- Decide the lifespan at the start.
Internal links
- Spatial dashboards explained: when an app beats a map image β the app case in detail
- How to package GIS deliverables β publishing the data
- How to build a print-ready map layout in Matplotlib β the report route
- How to make a map series with consistent symbology β small multiples
- Reproducible GIS workflows in Python β what a notebook needs to stay runnable
- Sharing a map app: public, private and in between β if it is an app
- Reproducible GIS environments explained β why notebooks age badly
- Streamlit, Dash or Panel: choosing a framework for a map app β if an app is the answer
FAQ
Should I send a notebook or a report?
A report for anybody who will not run it, and a notebook for peers who will. Pair either with the data, which is what most people are actually asking for.
When is an app worth building?
When the number of combinations is too large for a set of figures and the reader has to choose. One question for one person is a figure.
What does an app cost after it is built?
A URL people bookmark, an environment that must keep resolving, a deployment, and a per-interaction cost β measured from 13 ms a rerun for a small app to 0.81 s for one that rebuilds its map layer.
Are small multiples better than an interactive filter?
Frequently, for a handful of fixed cases. The reader compares on one page instead of holding earlier states in memory.
Why publish the data as well?
It costs an hour, satisfies most requests that arrive as dashboard requests, and outlives the tooling. It also invites checking, which is the right position for analysis that matters.
How do I stop an app becoming a liability?
Give it an owner and a review date at the start, and write both in the README. Decommissioning then has a plan rather than being an outage.