Streamlit vs Dash for Geospatial Dashboards
Both frameworks put a Python map in a browser, and for a single-page filter-and-render app either will do — the choice only starts to matter when the map has to remember where the user panned it, when the layer stops fitting in one payload, and when the app needs a second replica. This guide is for anyone about to commit a map dashboard to a repository that other people will maintain. It sits under Geospatial Dashboards & App Deployment in Web Mapping & Interactive Visualization, and assumes you have seen at least one of them working — the Streamlit side is built end to end in building a Streamlit map dashboard with Folium.
Why This Approach / What Goes Wrong
Every practical difference between the two frameworks falls out of one design decision: what a widget interaction actually executes.
Streamlit re-runs the entire script. Move a slider and the file executes again from line one in the same Python process, with widget values restored from st.session_state. There is no callback registry to reason about, which is exactly why a working map takes twenty-five lines — but it also means every object on the page is rebuilt each time, including the map. Your folium.Map is a new object with a new default centre unless you deliberately carry the old view forward.
Dash builds a dependency graph at import time. Each @app.callback declares its Output props and the Input props that trigger it, and the browser POSTs to /_dash-update-component when one of those inputs changes. The server runs only the callbacks whose inputs fired and returns JSON for their declared outputs; every other component in the DOM is untouched. The map is a long-lived React component, so its pan and zoom survive by default — not because Dash preserves state, but because nothing overwrote it.
That flips the burden. In Streamlit, keeping map state is work you have to do; in Dash, losing it requires you to declare the map's view as a callback output. Two more consequences follow. First, memory: a Streamlit process holds one copy of each cached GeoDataFrame shared across sessions (st.cache_resource) plus one st.session_state dictionary per open tab, and because that dictionary lives in this process, a second replica needs sticky routing at the load balancer. Dash callbacks are stateless HTTP handlers, so shared state goes in a dcc.Store in the browser and you scale by adding gunicorn workers. Second, granularity: a rerun recomputes everything not behind a cache, whereas a callback recomputes exactly what it declares.
Prerequisites
streamlit>=1.42—st.fragmentis stable from 1.37,st.pydeck_chart(on_select=...)from 1.40, andst.login()/st.userfrom 1.42streamlit-folium>=0.24— suppliesst_folium()withreturned_objectsanduse_container_widthdash>=3.0—app.run()replaces the removedapp.run_server();Patchandbackground=Truecallbacks are includeddash-leaflet>=1.0— the 1.0 rewrite renamed the GeoJSON event props toclickData/n_clicksplotly>=6.0— MapLibre-backedpx.choropleth_mapandpx.scatter_map, replacing the deprecated*_mapboxtracesgeopandas>=1.0,folium>=0.17,pydeck>=0.9
pip install "streamlit>=1.42" "streamlit-folium>=0.24" "folium>=0.17" "pydeck>=0.9" \
"dash>=3.0" "dash-leaflet>=1.0" "plotly>=6.0" \
"geopandas>=1.0" "gunicorn>=22.0"
Keep these in two requirement files, not one. A Dash image that carries Streamlit doubles its layer size for nothing, and the two frameworks pull incompatible expectations about who owns the HTTP server.
Step-by-Step Implementation
The fairest comparison is the same app twice: a parcel layer, a year filter, and a click that populates a detail panel.
1. The Streamlit version — linear, with the cache doing the heavy lifting.
# streamlit_app.py — streamlit run streamlit_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 review", layout="wide")
@st.cache_data(show_spinner="Reading parcels…")
def load_parcels(path: str = "parcels.gpkg") -> gpd.GeoDataFrame:
parcels = gpd.read_file(path)
return parcels.to_crs("EPSG:4326") # Leaflet only speaks WGS84 lon/lat
parcels = load_parcels()
min_year = st.slider("Built after", 1900, 2020, 1960, step=10)
visible = parcels[parcels["year_built"] >= min_year]
m = folium.Map(location=[45.52, -122.68], zoom_start=12, tiles="CartoDB positron")
folium.GeoJson(
visible,
name="parcels",
tooltip=folium.GeoJsonTooltip(["parcel_id"]),
).add_to(m)
state = st_folium(
m,
key="parcel_map", # keeps the component mounted across reruns
height=560,
use_container_width=True,
returned_objects=["last_object_clicked_tooltip"],
)
st.write("Selected parcel:", state.get("last_object_clicked_tooltip"))
returned_objects is the single most important argument on this page: it whitelists which map events are sent back to Python, and therefore which ones trigger a rerun. Leave it at the default and a mouse drag reruns the script.
2. The Dash version — explicit wiring, nothing implicit.
# dash_app.py — python dash_app.py (production: gunicorn dash_app:server)
import dash_leaflet as dl
import geopandas as gpd
from dash import Dash, Input, Output, dcc, html
parcels = gpd.read_file("parcels.gpkg").to_crs("EPSG:4326") # read once, at import
app = Dash(__name__)
server = app.server # the WSGI object gunicorn binds to
app.layout = html.Div([
dcc.Slider(1900, 2020, 10, value=1960, id="min-year"),
dl.Map(
id="parcel-map",
center=[45.52, -122.68],
zoom=12,
style={"height": "560px"},
children=[
dl.TileLayer(url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png"),
dl.GeoJSON(id="parcel-layer", data=parcels.__geo_interface__),
],
),
html.Div("Click a parcel", id="parcel-detail"),
])
@app.callback(Output("parcel-layer", "data"), Input("min-year", "value"))
def filter_parcels(min_year: int) -> dict:
return parcels[parcels["year_built"] >= min_year].__geo_interface__
@app.callback(Output("parcel-detail", "children"), Input("parcel-layer", "clickData"))
def show_parcel(feature: dict | None) -> str:
if not feature:
return "Click a parcel"
return f"Parcel {feature['properties']['parcel_id']}"
if __name__ == "__main__":
app.run(debug=True)
Roughly twice the code, and every line of the difference buys something: center and zoom are initial props that no callback outputs, so panning survives the filter; and moving the slider sends back only the new data prop, never the tile layer or the detail panel.
3. Weigh the map component ecosystems against actual requirements.
Streamlit's map surface is three components: st_folium (Leaflet, DOM-rendered, comfortable to about 5–10k features), st.pydeck_chart (deck.gl, WebGL, hundreds of thousands of points, with on_select="rerun" returning picked objects), and st.map for a throwaway scatter. Dash's is dash-leaflet (a near-complete Leaflet binding, including cluster=True with superClusterOptions for marker clustering — see clustering map markers with Folium for the equivalent idea) and dcc.Graph wrapping a Plotly MapLibre trace such as px.choropleth_map. Neither ecosystem is meaningfully richer; they differ in how the components are driven.
4. Persist map state and update incrementally.
Streamlit's fix for the rerun is to carry the view forward through st.session_state and to fence expensive blocks behind st.fragment, which reruns only its own body when a widget inside it changes.
if "view" not in st.session_state:
st.session_state["view"] = {"center": [45.52, -122.68], "zoom": 12}
m = folium.Map(
location=st.session_state["view"]["center"],
zoom_start=st.session_state["view"]["zoom"],
tiles="CartoDB positron",
)
state = st_folium(m, key="parcel_map", returned_objects=["center", "zoom"])
if state.get("zoom") is not None: # write the view back for next rerun
st.session_state["view"] = {
"center": [state["center"]["lat"], state["center"]["lng"]],
"zoom": state["zoom"],
}
@st.fragment # this block reruns alone
def summary_panel(visible: gpd.GeoDataFrame) -> None:
metric = st.selectbox("Metric", ["area_m2", "assessed_value"], key="metric")
st.bar_chart(visible.groupby("zone")[metric].sum())
Dash's equivalents are dcc.Store (state that lives in the browser, so any replica can serve the next request) and Patch, which sends a partial mutation instead of a whole prop.
from dash import Patch, State
app.layout.children.append(dcc.Store(id="selection", storage_type="session"))
@app.callback(
Output("event-log", "children"),
Input("parcel-layer", "clickData"),
State("selection", "data"),
prevent_initial_call=True,
)
def log_click(feature: dict, selection: dict | None):
log = Patch() # only the appended <li> crosses the wire
log.append(html.Li(feature["properties"]["parcel_id"]))
return log
5. Add authentication and decide how it scales.
Streamlit ships OpenID Connect natively: put redirect_uri, cookie_secret and a provider block in .streamlit/secrets.toml, then gate the script.
if not st.user.is_logged_in:
st.login("okta") # provider name matches the [auth.okta] secrets section
st.stop()
st.caption(f"Signed in as {st.user.email}")
Dash gets the same from dash-auth, applied to the whole Flask app rather than to a line of the script:
import dash_auth
auth = dash_auth.OIDCAuth(app, secret_key="…", idp_selection_route="/login")
auth.register_provider(
"okta",
client_id="…",
client_secret="…",
server_metadata_url="https://example.okta.com/.well-known/openid-configuration",
)
Scaling diverges here. Streamlit runs one process holding a WebSocket per session, so a second instance requires session affinity and each instance re-warms its own cache. Dash is an ordinary WSGI app whose callbacks are independent POSTs.
# Streamlit — one process; the load balancer must pin a session to it
streamlit run streamlit_app.py --server.address=0.0.0.0 --server.port=8501
# Dash — four stateless workers behind one port, no affinity needed
gunicorn --workers 4 --timeout 120 --bind 0.0.0.0:8050 dash_app:server
For anything longer than a couple of seconds, Dash offers @app.callback(..., background=True, manager=DiskcacheManager(...)), which hands the work to a separate process and streams progress back. Streamlit has no equivalent — a long computation blocks that session's script run. Either way, put the static bundle behind a cache but never the live connection, as covered in deploying a Python map app behind a CDN.
6. Apply the decision rule.
Verification
The claims worth checking are mechanical, and each framework lets you assert its own. On the Streamlit side, prove the cache is actually absorbing the rerun — add this to streamlit_app.py and move the slider twice.
import time
t0 = time.perf_counter()
parcels = load_parcels()
elapsed_ms = (time.perf_counter() - t0) * 1000
st.caption(f"load_parcels(): {elapsed_ms:.1f} ms")
assert elapsed_ms < 50 or "warmed" not in st.session_state, "cache is not hitting"
st.session_state["warmed"] = True
# First script run: load_parcels(): 842.7 ms
# Every rerun after: load_parcels(): 0.4 ms
On the Dash side, assert that no callback ever writes the map's view props — this is the invariant that keeps pan and zoom alive, and it is easy to break months later by adding a "zoom to selection" feature.
# verify_callbacks.py — python verify_callbacks.py
from dash_app import app
output_keys = list(app.callback_map.keys())
print(output_keys)
# ['parcel-layer.data', 'parcel-detail.children']
view_props = ("parcel-map.center", "parcel-map.zoom", "parcel-map.viewport")
assert not any(p in key for key in output_keys for p in view_props), (
"A callback outputs the map view — every update will reset the user's pan/zoom"
)
print(f"OK: {len(output_keys)} callbacks, none of them touch the map view")
# OK: 2 callbacks, none of them touch the map view
Edge Cases & Debugging
- The Streamlit map resets to its default centre on every filter. You rebuilt
folium.Mapwithout reading the stored view. Restorelocationandzoom_startfromst.session_stateas in step 4, and add"center"and"zoom"toreturned_objects. - Dragging the Streamlit map reruns the whole script.
returned_objectswas left at its default, so pan and zoom events are returned and count as widget changes. Whitelist only the events you consume. dl.GeoJSONclick handler receivesNone. dash-leaflet 1.0 renamed the props:click_featurebecameclickDataandn_clicks_featurebecamen_clicks. Code written against 0.x fails silently because Dash accepts unknown props on the Python side only in older versions — pindash-leaflet>=1.0and update theInput.- Serializing a large layer to JSON dominates every Dash callback.
__geo_interface__builds a Python dict, then Dash serializes it to JSON. Above a few megabytes, switchdl.GeoJSONtoformat="geobuf"withdash_leaflet.express.geojson_to_geobuf(needspygeobuf), or stop sending geometry and point the layer at a tile endpoint instead — see generating PMTiles from GeoParquet. - Areas or distances shown in the dashboard are wrong. Both frameworks want WGS84 (EPSG:4326) for display, which is a geographic CRS — computing anything metric in it, or in Web Mercator, gives distorted answers. Measure in a local projected CRS first (
gdf.estimate_utm_crs()), thento_crs("EPSG:4326")purely for rendering; the rules are set out in Coordinate Systems with PyProj.
Frequently Asked Questions
Can I get Dash-style partial updates in Streamlit?
Partly. st.fragment decorates a function so that widget interactions inside it rerun only that function, leaving the rest of the page as it was — which covers the common case of a control panel that should not disturb the map. What it cannot do is express dependencies between fragments the way a callback graph does, so a change that must ripple from one panel into two others still reruns the whole script.
Which one handles a million-feature layer better?
Neither, because at that size the framework is not the bottleneck — the payload is. st_folium and dl.GeoJSON both render through Leaflet's DOM layer and will stall well before a million features. The answer in both stacks is the same: stop sending geometry. Render from a tile endpoint or a PMTiles archive and let the dashboard ship a URL template, which keeps the payload flat as the dataset grows.
Does the choice affect how I authenticate users?
It changes where the check goes, not what it costs. Streamlit's st.login() gates a script line and is the shortest path to OIDC for a small internal tool. dash_auth.OIDCAuth wraps the Flask app, so protection applies to every route including the callback endpoint, which matters if the app also serves data. In both cases put the identity provider in front and never reimplement password handling in the dashboard.
Is Streamlit's single process a hard scaling limit?
It is a limit on replicas, not on users. One Streamlit container serves many concurrent sessions comfortably as long as the heavy objects are behind st.cache_resource and no session runs long blocking work. The constraint appears when you add a second container: session state lives in the process that created it, so the load balancer must keep each browser pinned to the same instance, and each instance warms its own cache from cold.