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_buttonneeds 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.
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.
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.
Example 2 โ a link to a pre-generated bulk file
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.
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.
Internal links
- How to build a Streamlit app with an interactive map โ the app the button belongs to
- How to cache spatial data in a map app โ the caching that makes it cheap
- How to add filters and widgets that drive a map โ what is being exported
- How to package GIS deliverables โ the bulk file
- How to export multiple formats in batch โ producing the bulk file
- GIS file formats compared โ choosing what to offer
- Fixing a GeoDataFrame to_file field type error โ export failures
- How to let users draw an area of interest in a map app โ defining the selection
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.