Geospatial Dashboards & Deploying Python Map Apps
A map that only renders in a notebook cell is an analysis, not a product. This stage of Web Mapping & Interactive Visualization covers the step after the map works: choosing an app shape, keeping the payload small enough for a browser, and deploying the result so it survives real traffic. It sits above the rendering layers — Interactive Maps with Folium for Leaflet-backed HTML and MapLibre GL Vector Web Maps for GPU-rendered vector styles — and consumes the output of Vector Tile Pipelines with PMTiles whenever the data outgrows a single file download. Four focused walkthroughs sit beneath it: building a Streamlit map dashboard with Folium, serving raster tiles from FastAPI with TiTiler, Streamlit vs Dash for geospatial dashboards, and deploying a Python map app behind a CDN.
Architecture & Data Structures
Every deployed Python map collapses into one of three shapes, and the shape is a consequence of where the work happens on each user interaction.
- Static export. Python runs once, at build time, and emits files: an HTML page, a GeoJSON or PMTiles payload, a style document. The browser does everything after that. There is no server process, no session, and no per-request cost — object storage plus a CDN is the whole deployment.
- Dashboard app. A long-lived Python process holds state per user and re-executes application code when a widget changes. Streamlit and Dash both live here. This is the only shape that can run arbitrary Python — a filter against PostGIS, a model, a reprojection — in response to a click.
- Tile / data service. A stateless HTTP API that answers
GET /tiles/{z}/{x}/{y}orGET /features?bbox=…with bytes. FastAPI (optionally wrapped by TiTiler for rasters) is the standard choice. It scales horizontally, caches trivially, and knows nothing about who is asking.
Real deployments usually combine two of them: a dashboard container for the controls and a tile service (or a static tile archive) for the pixels. The dashboard sends the browser a reference to the tile endpoint rather than the geometry itself, which is what keeps the dashboard's payload flat as the dataset grows.
The state each shape holds is worth naming explicitly, because it determines how the thing scales. A static export holds none — the deployable artifact is a directory. A dashboard holds two distinct things that are easy to conflate: session state, a per-user dictionary of widget values keyed to one open browser tab, and the process cache, a per-container store of expensive results shared by every session that container serves. A tile service holds neither; its only long-lived object is a connection pool or a GDAL dataset handle, which is why it can be replicated freely. When a dashboard suddenly needs more than one replica, it is the session state — not the cache — that forces sticky routing.
# The three shapes, reduced to their entry points.
# 1. Static export — Python runs once, writes a file, then exits.
import folium
m = folium.Map(location=[45.07, 7.69], zoom_start=12, tiles="CartoDB positron")
m.save("dist/index.html") # upload dist/ to object storage; no server
# 2. Dashboard app — a process that reruns on interaction.
# streamlit run app.py --server.address=0.0.0.0 --server.port=8501
# 3. Tile / data service — a stateless ASGI app.
# uvicorn tiles:app --host 0.0.0.0 --port 8000 --workers 4
Environment Configuration & Dependency Resolution
Dashboard and service stacks pin differently, so keep them in separate requirement sets even when they ship in the same repository. A tile service should not carry Streamlit, and a dashboard should not carry GDAL if it never opens a raster.
# Dashboard container
pip install "streamlit>=1.37" "streamlit-folium>=0.22" "folium>=0.17" \
"geopandas>=1.0" "pyogrio>=0.9"
# Tile / data service container
pip install "fastapi>=0.110" "uvicorn[standard]>=0.29" \
"sqlalchemy>=2.0" "psycopg[binary]>=3.1" "titiler.core>=0.18"
Three pins carry real behaviour rather than bug fixes. streamlit>=1.37 is the first release where st.fragment is stable rather than experimental, and fragments are the main tool for containing rerun cost. streamlit-folium>=0.22 supplies st_folium(..., use_container_width=True) and the returned_objects argument that decides which map events trigger a rerun. geopandas>=1.0 makes pyogrio the default I/O engine, which matters here because container cold-start time is dominated by the first read of the dataset.
Two environment settings belong in the image rather than in code. Streamlit's file watcher is useless in a container and burns CPU proportional to the size of site-packages, and any service reading Cloud-Optimized GeoTIFFs over HTTP needs GDAL told not to list the whole bucket prefix on every open:
# Dashboard image
ENV STREAMLIT_SERVER_FILE_WATCHER_TYPE=none \
STREAMLIT_SERVER_HEADLESS=true \
STREAMLIT_BROWSER_GATHER_USAGE_STATS=false
HEALTHCHECK CMD curl -fs http://localhost:8501/_stcore/health || exit 1
CMD ["streamlit", "run", "app.py", "--server.address=0.0.0.0", "--server.port=8501"]
# Raster tile image — the standard GDAL/VSI tuning for remote COGs
ENV GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR \
CPL_VSIL_CURL_ALLOWED_EXTENSIONS=".tif,.TIF,.tiff" \
GDAL_HTTP_MULTIPLEX=YES \
VSI_CACHE=TRUE \
VSI_CACHE_SIZE=536870912
Omitting GDAL_DISABLE_READDIR_ON_OPEN is the single most common cause of a raster endpoint that works locally against a file path and then takes several seconds per tile against object storage, because GDAL issues a directory listing before every open.
Vectorized Operations & Core Workflow
One number decides the architecture before any code is written: the bytes the browser must download to draw one viewport. Under roughly two megabytes, inline GeoJSON is simpler than anything else and you should ship it. Above that, the browser spends longer parsing JSON and building DOM or GPU buffers than it does downloading, and the fix is not a faster server — it is sending less, which means tiles.
Before conceding that a dataset is too big, shrink it — the reductions are vectorized and usually cut the payload by an order of magnitude. Drop every attribute the map does not draw or label, simplify in a metric CRS with a tolerance tied to the zoom you actually publish, and round coordinates: six decimal places in EPSG:4326 is about 11 cm, and anything beyond that is bytes spent on noise.
import geopandas as gpd
import shapely
parcels = gpd.read_file("parcels.gpkg", columns=["parcel_id", "land_use"])
# 1. Simplify in metres, not degrees — reproject to the local UTM zone first.
utm = parcels.estimate_utm_crs()
parcels_m = parcels.to_crs(utm)
parcels_m["geometry"] = parcels_m.geometry.simplify(5.0, preserve_topology=True)
# 2. Snap to a ~11 cm grid in EPSG:4326 so coordinates serialize short.
wgs = parcels_m.to_crs("EPSG:4326")
wgs["geometry"] = gpd.GeoSeries(
shapely.set_precision(wgs.geometry.to_numpy(), 1e-6), crs="EPSG:4326"
)
# 3. Emit RFC 7946 GeoJSON: WGS84, lon/lat order, no feature ids.
payload = wgs.to_json(drop_id=True, to_wgs84=True)
print(f"{len(payload.encode()) / 1e6:.2f} MB")
With the payload measured, the dashboard itself is short. The pattern below is the canonical Streamlit map app: a cached loader, a widget, a Folium map, and a return value from the map used to drive a detail panel. The expanded version, including layer toggles and click-to-select, is in building a Streamlit map dashboard with Folium.
# app.py — streamlit run app.py
import folium
import geopandas as gpd
import streamlit as st
from streamlit_folium import st_folium
st.set_page_config(page_title="Parcel viewer", layout="wide")
@st.cache_data(ttl=3600, show_spinner="Loading parcels…")
def load_parcels(path: str, tolerance_m: float) -> gpd.GeoDataFrame:
"""Runs once per (path, tolerance) pair — not once per widget change."""
gdf = gpd.read_file(path, columns=["parcel_id", "land_use"])
utm = gdf.estimate_utm_crs()
gdf = gdf.to_crs(utm)
gdf["geometry"] = gdf.geometry.simplify(tolerance_m, preserve_topology=True)
return gdf.to_crs("EPSG:4326") # web maps expect WGS84
parcels = load_parcels("parcels.gpkg", tolerance_m=5.0)
land_use = st.sidebar.selectbox("Land use", ["all", *sorted(parcels["land_use"].unique())])
if land_use != "all":
parcels = parcels[parcels["land_use"] == land_use] # safe: cache_data returns a copy
m = folium.Map(location=[45.07, 7.69], zoom_start=12, tiles="CartoDB positron")
folium.GeoJson(
parcels,
name="parcels",
tooltip=folium.GeoJsonTooltip(fields=["parcel_id", "land_use"]),
).add_to(m)
# returned_objects limits which map events come back — and therefore which rerun.
state = st_folium(m, height=600, use_container_width=True,
returned_objects=["last_object_clicked"])
if state and state.get("last_object_clicked"):
st.sidebar.write(state["last_object_clicked"])
Payload & Rerun Processing Details
Streamlit's execution model is the thing that surprises people who arrive from Flask or Dash: there are no callbacks. Any widget interaction reruns the entire script from line one, in the same process, with widget values restored from session state. Reading a GeoPackage at module scope therefore reads it again on every checkbox toggle. Nothing about this is a bug — it is what makes the code linear — but it means caching is not an optimization here, it is a prerequisite.
Three tools bound the cost, and they are not interchangeable:
@st.cache_data— for values. Keyed on the function's qualified name plus the hash of its arguments; the return value is serialized and a copy is handed back on each hit, so mutating the result cannot corrupt the cache. Use it for GeoDataFrames, query results, and rendered payloads. Prefix a parameter with_(e.g._engine) to exclude an unhashable argument from the key.@st.cache_resource— for handles. Returns the same object to every session with no copying: SQLAlchemy engines, PostGIS connection pools, loaded models. Never use it for a GeoDataFrame you intend to filter, because two sessions would share one object.@st.fragment— for scope. A decorated function reruns on its own when a widget inside it changes, leaving the rest of the script untouched. This is the correct fix when a map click should update a chart but not reload the data.
The second half of the story is what you avoid sending at all. Once the dataset is large or shared between several dashboards, move it behind a tile endpoint and let the dashboard render a URL template instead of geometry. The PostGIS route is compact: ST_TileEnvelope builds the tile's bounds in Web Mercator, ST_AsMVTGeom clips and quantizes each geometry into tile coordinates, and ST_AsMVT aggregates the rows into one protobuf blob. Point it at a table indexed as described in spatial indexing in PostGIS with GiST and the per-tile query stays in single-digit milliseconds.
# tiles.py — uvicorn tiles:app --host 0.0.0.0 --port 8000 --workers 4
from fastapi import FastAPI, HTTPException, Response
from sqlalchemy import create_engine, text
app = FastAPI(title="Parcel vector tiles")
engine = create_engine("postgresql+psycopg://gis@db/city", pool_size=8, pool_pre_ping=True)
MVT_SQL = text("""
WITH bounds AS (SELECT ST_TileEnvelope(:z, :x, :y) AS geom) -- EPSG:3857
SELECT ST_AsMVT(t, 'parcels', 4096, 'geom')
FROM (
SELECT p.parcel_id,
p.land_use,
ST_AsMVTGeom(ST_Transform(p.geom, 3857), bounds.geom, 4096, 64, true) AS geom
FROM parcels p, bounds
-- transform the tile box into the TABLE's CRS so the GiST index is used
WHERE p.geom && ST_Transform(bounds.geom, 4326)
) AS t
""")
@app.get("/tiles/parcels/{z}/{x}/{y}.pbf")
def parcel_tile(z: int, x: int, y: int) -> Response:
if not 0 <= z <= 16:
raise HTTPException(status_code=404, detail="zoom outside published range")
with engine.connect() as conn:
(mvt,) = conn.execute(MVT_SQL, {"z": z, "x": x, "y": y}).one()
if not mvt:
return Response(status_code=204) # empty tile is normal, not an error
return Response(
content=bytes(mvt),
media_type="application/vnd.mapbox-vector-tile",
headers={"Cache-Control": "public, max-age=300, s-maxage=86400"},
)
Note the && bounding-box operator in the WHERE clause rather than ST_Intersects on the transformed geometry: the index lives on the stored column, so the tile envelope is transformed to the table's CRS instead of transforming a million rows into the tile's. The same reasoning appears in connecting GeoPandas to PostGIS with SQLAlchemy — push the predicate to the indexed side.
CRS Alignment & Projection Pipeline
Web delivery has exactly two coordinate systems, and mixing them up produces silent misplacement rather than an exception.
- EPSG:4326 for data payloads. RFC 7946 requires GeoJSON to be WGS84 longitude-then-latitude decimal degrees. GeoPandas emits that faithfully with
to_json(to_wgs84=True), and both Folium and MapLibre assume it. Folium's Python arguments, however, take[lat, lon]—folium.Map(location=[45.07, 7.69])is latitude first while the GeoJSON it renders is longitude first. Reversed coordinates that land in the Gulf of Guinea are almost always this, not a projection error; the deeper diagnosis lives in Coordinate Systems with PyProj. - EPSG:3857 for the tile grid. The
WebMercatorQuadtile matrix that{z}/{x}/{y}addresses is Web Mercator, and every tiler —ST_AsMVTGeom, TiTiler, tippecanoe's output convention — assumes it. This is a rendering projection only. Its scale factor grows with latitude, so an area or distance computed in EPSG:3857 is wrong by a factor of roughly1/cos(latitude)².
The practical rule for a dashboard is that projection is a pipeline with three fixed stages, and the metric stage never touches either web CRS:
import geopandas as gpd
sensors = gpd.read_file("sensors.gpkg") # whatever CRS it arrived in
if sensors.crs is None:
sensors = sensors.set_crs("EPSG:4326") # declare the KNOWN source; do not guess
# 1. Analyse in a metric CRS — the local UTM zone, never 3857.
metric = sensors.to_crs(sensors.estimate_utm_crs())
metric["catchment"] = metric.geometry.buffer(500) # 500 real metres
# 2. Hand off to the web layer in WGS84.
catchments = metric.set_geometry("catchment").to_crs("EPSG:4326")
# 3. The tile grid's 3857 is applied by the tiler, not by you.
assert catchments.crs.to_epsg() == 4326
Reprojecting inside a request handler is a smell. Do it once in the cached loader (a dashboard), once in the tile build (a static export), or once in the ingest job that populates PostGIS — never per interaction. Where a whole raster stack has to be aligned before it can be tiled, reproject_match from the raster-cube workflow does it in one call; see reprojecting raster cubes with reproject_match.
Production Export & Integration
Deployment is where the three shapes converge on one topology: a container behind a CDN, with the bulk data in object storage. What differs is which responses may be cached and by whom, and getting the headers wrong is the most common reason a correct app feels slow or fails in a browser but works in curl.
The integration checklist, in the order things break:
- Version tile URLs, then cache them forever. A path like
/tiles/v2026-08-01/parcels/{z}/{x}/{y}.pbfcan carrymax-age=31536000, immutablebecause a rebuild changes the path. An unversioned path cannot, and must fall back tos-maxageplusETagrevalidation. - Configure CORS once, explicitly. A browser blocks a cross-origin tile fetch long before your handler runs. The wildcard
allow_origins=["*"]is rejected outright whenallow_credentials=True, so list the origins. - Expose range headers for PMTiles. A PMTiles archive is read with HTTP range requests; if the proxy strips
Accept-Rangesor the CORS policy does not exposeContent-Range, the client silently falls back to whole-file reads or fails. - Serve rasters through a purpose-built tiler. TiTiler wraps
rio-tilerin FastAPI routers and handles rescaling, colormaps and nodata correctly — see serving raster tiles from FastAPI with TiTiler and the windowed reads from Cloud-Optimized GeoTIFF that make it viable. - Scale the shapes differently. A tile service is stateless: run
--workers 4and add replicas. A Streamlit app is not — session state lives in one process, so replicas need sticky sessions at the load balancer and each replica keeps its own copy of every cached GeoDataFrame.
# raster_tiles.py — a COG tile service in nine lines
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from titiler.core.errors import DEFAULT_STATUS_CODES, add_exception_handlers
from titiler.core.factory import TilerFactory
app = FastAPI(title="Ortho tile service")
app.add_middleware(
CORSMiddleware,
allow_origins=["https://maps.example.org"],
allow_methods=["GET"],
allow_headers=["Range"],
expose_headers=["Content-Range", "Accept-Ranges", "Content-Length", "ETag"],
max_age=86400,
)
cog = TilerFactory(router_prefix="/cog")
app.include_router(cog.router, prefix="/cog", tags=["Cloud Optimized GeoTIFF"])
add_exception_handlers(app, DEFAULT_STATUS_CODES)
# GET /cog/tiles/WebMercatorQuad/12/2185/1497.png?url=s3://imagery/ortho.tif
Two operational settings finish the picture. Give the tile route a hard timeout — a request that has already spent five seconds rendering will not satisfy a browser that has moved on, and an unbounded handler turns one slow COG into a saturated worker pool. And log the tile coordinates plus the elapsed time per request; a heat map of slow {z}/{x}/{y} triples localizes the problem to a specific zoom band or a specific source file far faster than aggregate latency percentiles do.
The remaining decision — whether the dashboard container itself sits behind the CDN — is covered in deploying a Python map app behind a CDN. The short answer is that only its static assets benefit; the WebSocket connection carrying the app's state must bypass the cache entirely.
Windows / Platform Edge Cases & Debugging
- App works locally, unreachable in Docker. Streamlit binds to localhost by default; add
--server.address=0.0.0.0, and expose8501. Health checks should hit/_stcore/health, not/. - "Please wait…" forever behind nginx or an ALB. The reverse proxy is not upgrading the WebSocket connection. Streamlit and Dash's live callbacks both need
Upgrade/Connectionheaders passed through and a generous idle timeout. - Blank page under a subpath. Mounted at
/mapswithout--server.baseUrlPath=maps, so every static asset 404s. Set it, and do not add a trailing slash. missing ScriptRunContextwarnings. The file was launched withpython app.pyinstead ofstreamlit run app.py; no Streamlit runtime exists, so widgets are no-ops.UnhashableParamErrorfromst.cache_data. An argument (a SQLAlchemy engine, a connection, aTransformer) has no stable hash — rename the parameter to_engineso it is excluded from the cache key, or move it into anst.cache_resourcesingleton.- Memory climbs until the container is killed. Every worker holds its own cache; a 400 MB GeoDataFrame times four workers exceeds a 1 GB limit. Set
max_entriesandttlonst.cache_data, or move the data behind a tile endpoint. - The map is empty but the network tab shows 200s. Either the
Content-Typeis wrong (a.pbfserved asapplication/octet-streamis ignored by MapLibre) or the tiles are gzipped withoutContent-Encoding: gzip— MBTiles stores MVT gzip-compressed, so it must be declared or decompressed. - CORS error only in the browser.
curldoes not sendOrigin. Reproduce withcurl -H "Origin: https://maps.example.org" -I <url>and check foraccess-control-allow-originin the response. - Windows:
DLL load failedimporting pyproj or rasterio inside the app. A conda GDAL onPATHis shadowing the wheel's bundled libraries. Build the image frompython:3.12-slimwith pip wheels only rather than mixing pip and conda — the same failure mode as in installing and configuring GeoPandas on Windows. - Every pan or zoom reruns the script.
st_foliumreturns map state by default. Passreturned_objects=["last_object_clicked"]— or[]— so viewport changes stop triggering reruns.
Frequently Asked Questions
How large can a GeoJSON payload be before I need tiles?
The practical ceiling is about 2 MB per viewport for a smooth experience, and roughly 10 MB before the browser becomes visibly unresponsive — parsing and geometry construction, not download, dominate past that point. Measure before deciding: serialize with GeoDataFrame.to_json() and check len(payload.encode()). Dropping unused attributes, simplifying in a metric CRS, and rounding to six decimal places routinely cuts a 12 MB export to under 2 MB, which is cheaper than building a tile pipeline. If it is still large after that, or if the data grows over time, move to vector tile pipelines with PMTiles.
Should I use st.cache_data or st.cache_resource for a GeoDataFrame?
st.cache_data. It hashes the arguments, serializes the return value, and hands each caller a copy, so filtering or adding a column in one session cannot corrupt another's view of the data. st.cache_resource returns the same object to every session with no copy — correct for a SQLAlchemy engine or a loaded model, and dangerous for a frame you intend to mutate. The one case for st.cache_resource with spatial data is a read-only spatial index or a PostGIS connection pool shared across sessions.
Can a CDN sit in front of a Streamlit or Dash app?
Partially, and it is worth doing for the static bundle only. Both frameworks maintain a live connection — a WebSocket in Streamlit, callback POSTs in Dash — that carries per-user state and must never be cached or coalesced. Configure the CDN to cache the framework's static asset paths aggressively and to pass everything else through with no-store. The real win is putting the tiles behind the CDN, since they are identical for every user and represent the overwhelming majority of bytes.
Do I still need a tile server if I already have PMTiles? No — that is the point of the format. A PMTiles archive is a single file read by the client with HTTP range requests, so object storage plus a CDN serves it with no Python process running. You need a server again only when tiles must be generated from live data, filtered per user, or access-controlled per layer; then a FastAPI service producing MVT (or serving MBTiles with Python) is the fallback.
Streamlit or Dash for a geospatial dashboard? Streamlit's top-to-bottom rerun model gets a working map in a page of code and is the right default for internal tools and analysis apps. Dash's explicit callback graph updates only the components you declare as outputs, which matters when a single page holds a map, several charts, and a table that must not all recompute together. The full comparison — including how each handles map click events and large payloads — is in Streamlit vs Dash for geospatial dashboards.
Why does my map render at the wrong location with correct-looking coordinates?
Almost always axis order. GeoJSON is longitude-then-latitude by specification, while Folium's Python arguments (location, bounds, marker positions) are latitude-then-longitude, and PyProj transformers return authority axis order unless built with always_xy=True. A point that should be in Turin appearing off the coast of Africa is the classic signature of one reversed pair; verify with a single known coordinate before suspecting the projection.