Notebook, App or Report: Choosing How to Ship an Analysis
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 โ measured at 0.23โ0.25 s uncached and 0.04โ0.06 s cached on a small layer โ 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"
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.
Step-by-step solution
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.
Code examples
Example 1 โ the same analysis, three deliverables
"""One analysis module; three thin shells around it."""
import geopandas as gpd
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=2025, threshold=8.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="districts.gpkg"):
districts.to_file(out, driver="GPKG", layer="districts")
with open("README.md", "w") as handle:
handle.write("# Districts\n\nSource: โฆ ยท CRS: EPSG:27700 ยท "
"Extracted: 2026-09-05\n")
return out
# 3. app: the same function, behind widgets
def app(districts):
import streamlit as st
year = st.selectbox("Year", YEARS)
threshold = st.slider("Threshold (%)", 0.0, 20.0, 8.0)
findings = analyse(districts, year, threshold)
st.metric("Above threshold", findings["above"])
st.write(findings["worst"])
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"):
"""Six 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]]
bins = bins or list(mapclassify.Quantiles(values.stack(), 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)
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
Six 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 ""))
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.
Explanation
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 on a small layer, an interaction costs 0.04โ0.06 s cached and 0.23โ0.25 s uncached; on a large one it is seconds, 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.
Edge cases or notes
- 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 at 0.04โ0.06 s cached and 0.23โ0.25 s uncached on a small 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.