How to Add a Download Button for Filtered Spatial Data

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:

  • Streamlit's download_button needs the bytes before the user clicks, so a naive implementation serialises the whole dataset on every rerun
  • the format matters: a shapefile is four files, a GeoPackage is binary, and a CSV loses the geometry
  • a filtered selection can still be enormous โ€” measured, 4,596 polygons are 54 MB as GeoJSON
  • clicking the button triggers a rerun, which surprises people the first time

Quick answer

import streamlit as st


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


@st.cache_data
def to_geojson(_gdf, key: str) -> bytes:
    return _gdf.to_json().encode()


key = f"{selection.describe()}|{len(subset)}"        # identifies this selection

st.download_button("Download CSV", to_csv(subset, key),
                   file_name="selection.csv", mime="text/csv")
st.download_button("Download GeoJSON", to_geojson(subset, key),
                   file_name="selection.geojson", mime="application/geo+json")

The cache key is the whole trick: without it, every rerun re-serialises the data for a button nobody clicked.

Flow showing a download button serialising its file on every rerun unless cached.
The cache key must identify the selection, or two filters share one export.

Step-by-step solution

1. Understand when the bytes are produced

st.download_button takes the data as an argument, so the file is built when the button is rendered, not when it is clicked. In Streamlit's model that means on every rerun.

For a 50 MB export, that is 50 MB serialised every time the user moves a slider. Caching the serialisation on a key that identifies the selection reduces it to once per distinct selection.

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 four 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 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 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, 4,596 administrative polygons are 54.06 MB as GeoJSON and 18.82 MB as GeoParquet.

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"] = "ONS mid-2025"
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.

Grid of five export formats with their audience and caveats.
Two formats cover almost everybody: a CSV and a GeoPackage.

Code examples

Example 1 โ€” 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, use_container_width=True)
            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 40 MB download on a phone.

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 3 โ€” 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()

Two coordinate columns for the spreadsheet users and a WKT column for the GIS users, in one file. The comment line is ignored by most readers and answers the provenance question for the rest.

Explanation

Why the data is built before the click

st.download_button needs the bytes at render time, because the browser is given a data URL. There is no callback that fires on click to generate the file.

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 โ€” for a file nobody has asked for yet.

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 four files that must be zipped, truncates attribute names to ten characters and has no reliable encoding declaration. GeoJSON is text and large โ€” measured, 54.06 MB against 18.82 MB of GeoParquet for the same features. 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.

Table of four provenance fields a downloaded file should carry.
Within a week the download is an unattributable spreadsheet otherwise.

Edge cases or notes

  • Clicking a download button triggers a rerun. Expect it, and 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 st.download_button needs the bytes at render time, so the file is built on every rerun. 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 the four files. Warn that attribute names are truncated to ten characters.

How large a download can the app build?

Bound it โ€” measured, 4,596 polygons are 54.06 MB as GeoJSON. Cap the feature count and link to a pre-generated bulk file beyond it.

Does clicking the button re-run the app?

Yes. That is expected in Streamlit; make sure nothing in the render path has side effects.

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.