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.

The four ways to ship an analysis, compared on what they cost. four deliverables, four cost profilesruntimeagesneeds an ownerreportβœ“noneβœ“gracefullynonotebookthe reader's!badlynoapp!yoursβœ•badlyβœ•yesdatasetβœ“noneβœ“gracefullylightlyThe last two columns are what people underestimate when they ask for a dashboard spatialworkflow.io
An app nobody maintains is a URL that eventually 502s.

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.

Six fixed cases on one page against six cases behind a slider. when small multiples wina year slider in an appβœ•one year visible at a timeβœ•comparison happens in memoryβœ•a deployment and an ownerβœ•every view is a rerunsix panels on one pageβœ“all years visible at onceβœ“the comparison is the figureβœ“no runtime at allβœ“shared class breaks make it honestAn app earns its cost when no set of figures covers the combinations spatialworkflow.io
For a handful of fixed cases the figure is the better tool as well as the cheaper one.

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.

Pairing every deliverable with the data behind it. publish the data, whatever else you ship1report + datasetthe reader can check the numbers2report + notebookthe reader can check the method3app + dataset + method noteexplore and verify4app aloneoutputs with no derivationA GeoPackage on a URL with a README costs an hour and outlives the tooling spatialworkflow.io
An analysis that ships its data invites checking; one that does not asks to be trusted.

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.

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.