How to Add a Download Button for Filtered Spatial Data

On this page

Problem statement

Every map app receives the same request: "can you send me the data behind that?" A download button answers it once, permanently, in about three lines β€” and it is the single highest-value feature most spatial apps are missing.

The complications are all in the details:

  • given bytes, Streamlit's download_button needs them before the user clicks, so a naive implementation serialises the whole selection on every rerun β€” measured, 0.6 s a rerun for a GeoJSON button nobody pressed
  • the format matters: a shapefile is several files, a GeoPackage is binary, and a CSV loses the geometry
  • a filtered selection can still be enormous β€” measured, Europe's 1,333 NUTS 3 regions are 25.26 MB as GeoJSON
  • clicking the button triggers a rerun by default, which surprises people the first time

Streamlit now has an answer to the first and last: pass a function instead of bytes and the file is built only when somebody clicks, and on_click="ignore" stops the rerun.

Quick answer

%%writefile quick_app.py
import functools
import os
import tempfile

import geopandas as gpd
import streamlit as st


@st.cache_data
def load(path):
    return gpd.read_file(path)


@st.cache_data
def to_csv(_gdf, key: str) -> bytes:
    """Small and eager: serialised once per selection, not once per rerun."""
    return _gdf.drop(columns=_gdf.geometry.name).to_csv(index=False).encode()


def to_geopackage(gdf) -> bytes:
    """Large and deferred: Streamlit calls this only when the button is clicked."""
    with tempfile.TemporaryDirectory() as directory:
        path = os.path.join(directory, "selection.gpkg")
        gdf.to_file(path, layer="selection", driver="GPKG")
        with open(path, "rb") as handle:
            return handle.read()


districts = load("districts.gpkg")
floor = st.slider("Share aged 65+ at least (%)", 0.0, 40.0, 25.0, 0.5, key="floor")
subset = districts[districts["rate_2023"] >= floor]
key = f"65+ >= {floor}|{len(subset)}"                  # identifies this selection

st.download_button("Download CSV", to_csv(subset, key),
                   file_name="selection.csv", mime="text/csv", key="csv")
st.download_button("Download GeoPackage", functools.partial(to_geopackage, subset),
                   file_name="selection.gpkg", mime="application/geopackage+sqlite3",
                   on_click="ignore", key="gpkg")

A cached function keyed on the selection for the small file, a callable for the large one. Either way, no rerun serialises a file for a button nobody clicked.

st.download_button needs the bytes at render time, not at click time. why the button makes the app slowany widget changesthe script re-runsthe button isrenderedand needs its bytesthe file isserialisedevery rerun, uncachedcache on aselection keyonce per distinctselectionA 50 MB export serialised on every slider movement, for a file nobody clicked spatialworkflow.io
The cache key must identify the selection, or two filters share one export.

What you get β€” from The data used on this page

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

When the bytes are built, and in which formats

1. Understand when the bytes are produced

Given bytes, st.download_button has the file built when the button is rendered, not when it is clicked β€” in Streamlit's model, on every rerun. Streamlit keeps those bytes in memory while the user is connected and serves them from a URL.

Given a function with no arguments, it stores only an id and calls the function on click. Measured on the 1,333-region GeoJSON, a rerun cost 0.6 s with eager bytes, 0.60 s cached for a new selection and 0.03 s for one seen before, and 0.02 s deferred (Example 1). Cache small files on a key that identifies the selection; defer large ones.

2. Offer the formats people actually use

Format For Notes
CSV spreadsheets, quick checks loses the geometry unless you add WKT or lon/lat
GeoJSON web tools, other analysts large; round the coordinates
GeoPackage QGIS, ArcGIS, anything GIS one binary file, keeps types and CRS
GeoParquet Python and R users smallest and fastest to read back
Shapefile because somebody asked several files, must be zipped; truncates field names

Offer two: a CSV for the people who want numbers and a GeoPackage or GeoParquet for the people who want geometry.

3. Write file-based formats through a temporary file

GeoPackage and shapefile are written by GDAL to a path, not to a buffer:

import streamlit as st
import tempfile, os


@st.cache_data
def to_geopackage(_gdf, key: str, layer: str = "selection") -> bytes:
    with tempfile.TemporaryDirectory() as directory:
        path = os.path.join(directory, "selection.gpkg")
        _gdf.to_file(path, layer=layer, driver="GPKG")
        with open(path, "rb") as handle:
            return handle.read()

The temporary directory cleans itself up, and the bytes are what the button needs.

4. Zip a shapefile, or do not offer one

A shapefile is .shp, .shx, .dbf and .prj at minimum. A download of one component is useless:

import streamlit as st
import io, os, tempfile, zipfile


@st.cache_data
def to_shapefile_zip(_gdf, key: str, name: str = "selection") -> bytes:
    with tempfile.TemporaryDirectory() as directory:
        _gdf.to_file(os.path.join(directory, f"{name}.shp"))
        buffer = io.BytesIO()
        with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
            for filename in os.listdir(directory):
                archive.write(os.path.join(directory, filename), filename)
        return buffer.getvalue()

Warn about the field-name truncation while you are at it: shapefile attribute names are limited to ten characters.

5. Bound the export

A filtered selection can still be the whole dataset. Measured, Europe's 1,333 NUTS 3 regions at 1:1 million are 25.26 MB as GeoJSON and 10.06 MB as GeoParquet (Example 2).

MAX_EXPORT_FEATURES = 50_000

if len(subset) > MAX_EXPORT_FEATURES:
    st.warning(f"{len(subset):,} features is too many to download here. "
               f"Narrow the selection, or use the bulk download link.")
else:
    st.download_button(...)

For genuinely large exports, link to a pre-generated file or a background job rather than building it in the app.

6. Include the provenance in the file

A downloaded selection with no context becomes an unattributable spreadsheet within a week:

frame = subset.drop(columns=subset.geometry.name).copy()
frame.attrs["source"] = "Eurostat demo_r_pjanind3, 2023"
frame.attrs["extracted"] = datetime.date.today().isoformat()
frame.attrs["selection"] = selection.describe()

For CSV, a header comment or extra columns; for GeoPackage and Parquet, the metadata travels properly. Either way it costs nothing and answers the question that always follows.

Export formats a map app should offer, and what each is for. offer two: numbers and geometryforcaveatCSVβœ“spreadsheetsloses geometry unless you add WKTGeoPackageβœ“any GISone binary file, keeps types and CRSGeoParquetβœ“Python and Rsmallest, fastest to read backGeoJSONweb tools!large β€” 54 MB for 4,596 polygonsShapefilebecause somebody askedβœ•four files, 10-char field namesA GeoPackage is one file that keeps types, CRS and full field names spatialworkflow.io
Two formats cover almost everybody: a CSV and a GeoPackage.

A cached multi-format download of European regions

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 β€” what the button holds, and what a rerun costs

AppTest runs the quick-answer app headlessly and shows the two buttons as Streamlit sees them β€” the eager one already has a URL for its bytes, the deferred one only an id:

from streamlit.testing.v1 import AppTest

at = AppTest.from_file("quick_app.py", default_timeout=120).run()
for button in at.get("download_button"):
    kind = "bytes already at a media URL" if button.proto.url else "deferred: built on click"
    print(f"{button.proto.label:20} {kind:30} ignore_rerun={button.proto.ignore_rerun}")
print("exception:", list(at.exception))
Download CSV         bytes already at a media URL   ignore_rerun=False
Download GeoPackage  deferred: built on click       ignore_rerun=True
exception: []

The cost of each approach, for a GeoJSON button over the whole selection: the slider moves through four new values and then the same four again.

import statistics
import time

template = '''
import geopandas as gpd
import streamlit as st

MODE = "__MODE__"


@st.cache_data
def load(path):
    return gpd.read_file(path)


@st.cache_data
def cached_geojson(_gdf, key):
    return _gdf.to_json().encode()


districts = load("districts.gpkg")
floor = st.slider("Share aged 65+ at least (%)", 0.0, 40.0, 0.0, 0.5, key="floor")
subset = districts[districts["rate_2023"] >= floor]

if MODE == "eager":
    data = subset.to_json().encode()
elif MODE == "cached":
    data = cached_geojson(subset, f"{floor}|{len(subset)}")
else:
    data = lambda: subset.to_json().encode()
st.download_button("GeoJSON", data, "selection.geojson", key="geojson")
'''

for mode in ("eager", "cached", "deferred"):
    with open(f"bench_{mode}.py", "w") as out:
        out.write(template.replace("__MODE__", mode))
    at = AppTest.from_file(f"bench_{mode}.py", default_timeout=120).run()
    rounds = []
    for _ in range(2):
        times = []
        for value in (1.0, 2.0, 3.0, 4.0):
            started = time.perf_counter()
            at.slider(key="floor").set_value(value).run()
            times.append(time.perf_counter() - started)
        rounds.append(statistics.median(times))
    print(f"{mode:9} new selection {rounds[0]:.3f} s, seen before {rounds[1]:.3f} s")
print(f"load average {os.getloadavg()[0]:.1f}")
eager     new selection 0.592 s, seen before 0.583 s
cached    new selection 0.599 s, seen before 0.033 s
deferred  new selection 0.021 s, seen before 0.021 s
load average 1.4
Rerun time of a Streamlit app with a GeoJSON download button over 1,333 regions: eager bytes, cached bytes and a deferred callable. what an unclicked download button costs per reruneager bytes0.6 s, every reruncached, new selection0.6 scached, seen before0.03 sdeferred callable0.02 s, always00.35 s0.7 sPass a function and the file is built only when somebody clicks spatialworkflow.io
Timed with AppTest (Example 1). The cache only helps a selection seen before; deferring helps every rerun.

Example 2 β€” a download section with several formats, cached

import datetime
import io
import os
import tempfile
import zipfile

import streamlit as st

MAX_EXPORT_FEATURES = 50_000


@st.cache_data(max_entries=8, show_spinner=False)
def serialise(_gdf, key: str, fmt: str) -> bytes:
    """One cached serialiser for every format. `key` identifies the selection."""
    if fmt == "csv":
        return _gdf.drop(columns=_gdf.geometry.name).to_csv(index=False).encode()

    if fmt == "geojson":
        import shapely
        rounded = _gdf.copy()
        rounded["geometry"] = shapely.set_precision(rounded.geometry.values, 1e-6)
        return rounded.to_json().encode()

    if fmt == "parquet":
        buffer = io.BytesIO()
        _gdf.to_parquet(buffer)
        return buffer.getvalue()

    with tempfile.TemporaryDirectory() as directory:
        if fmt == "gpkg":
            path = os.path.join(directory, "selection.gpkg")
            _gdf.to_file(path, layer="selection", driver="GPKG")
            return open(path, "rb").read()
        if fmt == "shp":
            _gdf.to_file(os.path.join(directory, "selection.shp"))
            buffer = io.BytesIO()
            with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
                for filename in os.listdir(directory):
                    archive.write(os.path.join(directory, filename), filename)
            return buffer.getvalue()
    raise ValueError(fmt)


def download_section(subset, selection_label: str):
    st.subheader("Download")
    if len(subset) > MAX_EXPORT_FEATURES:
        st.warning(f"{len(subset):,} features β€” narrow the selection to download.")
        return

    stamp = datetime.date.today().isoformat()
    key = f"{selection_label}|{len(subset)}|{stamp}"

    formats = [("csv", "CSV (no geometry)", "text/csv"),
               ("gpkg", "GeoPackage", "application/geopackage+sqlite3"),
               ("parquet", "GeoParquet", "application/vnd.apache.parquet"),
               ("geojson", "GeoJSON", "application/geo+json")]

    columns = st.columns(len(formats))
    for column, (fmt, label, mime) in zip(columns, formats):
        payload = serialise(subset, key, fmt)
        with column:
            st.download_button(label, payload,
                               file_name=f"selection_{stamp}.{fmt}",
                               mime=mime, width="stretch")
            st.caption(f"{len(payload) / 1e6:.2f} MB")

    st.caption(f"{len(subset):,} features Β· {selection_label} Β· extracted {stamp}")

Showing the size under each button is a small courtesy that prevents somebody clicking a 25 MB download on a phone. The sizes, and the time to build each, for all 1,333 regions β€” the second call of each is the cache:

for fmt in ("csv", "geojson", "gpkg", "parquet", "shp"):
    timings = []
    for _ in range(2):
        started = time.perf_counter()
        payload = serialise(districts, "all regions", fmt)
        timings.append(time.perf_counter() - started)
    print(f"{fmt:8} {len(payload) / 1e6:6.2f} MB  built in {timings[0]:.2f} s, cached {timings[1] * 1000:.0f} ms")
print(f"unrounded GeoJSON: {len(districts.to_json()) / 1e6:.2f} MB")
csv        0.08 MB  built in 0.00 s, cached 0 ms
geojson   15.14 MB  built in 1.17 s, cached 2 ms
gpkg      11.44 MB  built in 0.04 s, cached 1 ms
parquet   10.06 MB  built in 0.04 s, cached 1 ms
shp        8.09 MB  built in 0.33 s, cached 1 ms
unrounded GeoJSON: 25.26 MB

Rounding the coordinates to six decimal places β€” about 11 cm β€” cuts the GeoJSON from 25.26 MB to 15.14 MB, and it is also the slowest step here: set_precision makes the rounded file take 1.17 s to build, which is why it sits behind the cache.

def bulk_download(url, size_mb, updated):
    """For anything too large to build in the app."""
    st.markdown(f"""
        **Full dataset** β€” {size_mb:,.0f} MB, updated {updated}

        [Download GeoParquet]({url})

        The app builds selections up to {MAX_EXPORT_FEATURES:,} features.
        For the whole dataset, use the link above.
    """)

The bulk file is produced by the pipeline, not by the app, which keeps the app's memory bounded and gives the largest consumers a faster route.

Example 4 β€” a CSV that keeps the geometry usable

@st.cache_data
def to_csv_with_coordinates(_gdf, key: str, precision: int = 6) -> bytes:
    """A CSV people can put back on a map."""
    frame = _gdf.drop(columns=_gdf.geometry.name).copy()
    points = _gdf.geometry.representative_point()
    frame["longitude"] = points.x.round(precision)
    frame["latitude"] = points.y.round(precision)
    frame["geometry_wkt"] = _gdf.geometry.to_wkt(rounding_precision=precision)

    header = (f"# extracted {datetime.date.today().isoformat()} Β· "
              f"{len(frame):,} features Β· CRS EPSG:4326\n")
    return (header + frame.to_csv(index=False)).encode()
oldest = districts[districts["rate_2023"] >= 30]
payload = to_csv_with_coordinates(oldest, "65+ >= 30")
print(f"{len(oldest)} regions, {len(payload) / 1e6:.2f} MB with WKT")
print(payload.decode().splitlines()[1][:118])
34 regions, 0.27 MB with WKT
code,country,name,category,rate_2019,rate_2020,rate_2021,rate_2022,rate_2023,longitude,latitude,geometry_wkt

Two coordinate columns for the spreadsheet users and a WKT column for the GIS users, in one file. The WKT is what makes it heavy: the plain CSV of all 1,333 regions was 0.08 MB. The comment line is ignored by most readers and answers the provenance question for the rest.

Selection-aware cache keys and GeoPackage defaults

When the data is built

Given bytes, st.download_button stores them in Streamlit's media store at render time and hands the browser a URL to them; they stay in memory while the user is connected. In a framework that re-runs the whole script, that means the serialisation happens on every interaction unless it is cached. On a large selection the button becomes the slowest thing in the app β€” 0.6 s a rerun here, for a file nobody has asked for yet.

Given a function, it stores an id and calls the function when the button is clicked, on a separate thread from the rerun. That is the better default for anything large; the cost is that the user waits after clicking rather than every time they touch a widget.

Why the cache key must describe the selection

@st.cache_data hashes the arguments, and a GeoDataFrame passed as _gdf is excluded from the key. Something else must identify which selection this is, or two different filters share a cache entry and the download contains the wrong rows.

A string built from the filter state and the row count is enough, and including the date means a data refresh produces a new key rather than serving yesterday's export.

Why a GeoPackage is the best default with geometry

It is a single file, it keeps types, it keeps the CRS, it supports several layers, it has no field-name limit, and every GIS reads it.

A shapefile is several files that must be zipped, truncates attribute names to ten characters and has no reliable encoding declaration. GeoJSON is text and large β€” measured, 25.26 MB against 10.06 MB of GeoParquet for the same 1,333 regions. GeoParquet is the best of the three for a Python or R user and unreadable to somebody opening it in Excel.

Why the download button is worth more than most features

It converts an ongoing stream of requests β€” "can you export the districts above 8%?" β€” into a self-service action. It also makes the app useful when it is down: the data has already been distributed.

And it is honest. An app that presents numbers without offering the numbers is asking to be trusted; one that offers them is inviting a check.

What a downloaded selection should carry with it. a file with no context is an orphanfieldexamplesourceONS mid-2025 estimatesextracted2026-09-05selectionNorth Β· 8.0–20.0% Β· 42 districtsCRSEPSG:4326a header comment in CSV; real metadata in GeoPackage and ParquetIt costs nothing and answers the question that always follows spatialworkflow.io
Within a week the download is an unattributable spreadsheet otherwise.

Reruns on click, zipped shapefiles and rounding

  • Clicking a download button triggers a rerun unless on_click="ignore" is set. Either way, do not put side effects in the render path.
  • Cache the serialisation on a key that identifies the selection, or it runs every rerun.
  • Shapefiles must be zipped and truncate field names to ten characters.
  • GeoPackage and shapefile are written to a path, so use a temporary directory.
  • Round coordinates on export β€” six decimal places is about 11 cm.
  • Show the file size next to the button.
  • Cap the export size and offer a bulk link beyond it.
  • Put the extraction date and the selection in the file, not only in the filename.

FAQ

Why is my app slow when I add a download button?

Because, given bytes, st.download_button has the file built on every rerun β€” 0.6 s a rerun for a 1,333-region GeoJSON. Pass a function instead so it is built on click, or cache the serialisation on a key that identifies the selection.

Which formats should I offer?

Two: a CSV for people who want numbers and a GeoPackage or GeoParquet for people who want geometry. Add GeoJSON if web tools are in the audience.

How do I let people download a shapefile?

Write it to a temporary directory and zip every file in it. Warn that attribute names are truncated to ten characters.

How large a download can the app build?

Bound it β€” measured, Europe's 1,333 NUTS 3 regions are 25.26 MB as GeoJSON. Cap the feature count, build large files on click by passing a function, and link to a pre-generated bulk file beyond that.

Does clicking the button re-run the app?

By default, yes. Pass on_click="ignore" to stop it, and make sure nothing in the render path has side effects either way.

Should the file include metadata?

Yes β€” the extraction date, the source and the selection. A downloaded file with no context becomes an unattributable spreadsheet within a week.