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_buttonneeds 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.
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.
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
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.
Example 3 β 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 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.
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.
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, 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.