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.

Streamlit's top-to-bottom rerun compared with Dash's callback dependency graph Two panels side by side. The left panel shows a Streamlit script of six numbered lines, from a cached parcel loader through a slider, a Folium map and a data table; a red arrow loops from the bottom of the script back to line one, and a note explains that any widget change re-executes all six lines in one process with values restored from session state. The right panel shows a Dash callback graph: a min-year slider value drives the filter_parcels callback, which outputs the GeoJSON layer's data prop, while a layer clickData event drives the show_parcel callback, which outputs the detail panel's children; a strip beneath says everything else gets no re-render and no server call, and a note explains that only callbacks whose inputs fired execute and that props never declared as outputs, such as the map view, persist untouched. Streamlit · script rerun 123 456 parcels = load_parcels() cache hit year = st.slider(...) visible = parcels[mask] m = folium.Map(...) out = st_folium(m, key=) st.dataframe(summary) Any widget change re-executes lines 1–6 one process · values restored from session_state Dash · callback graph min-year value layer clickData filter_parcels() outputs layer.data show_parcel() outputs detail.children GeoJSON layer one prop replaced detail panel children patched everything else: no re-render, no server call Only callbacks whose Inputs fired execute props never Output — like the map view — persist
One design decision, two shapes: Streamlit re-executes the file, Dash re-executes the subgraph reachable from the changed input.

Prerequisites

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.

Requirement-by-requirement comparison of Streamlit and Dash for map dashboards A seven-row matrix with a requirement column and one column each for Streamlit and Dash. Running a Python function on a map click is straightforward in both. Preserving pan and zoom is manual in Streamlit and automatic in Dash. Refreshing one panel of five needs st.fragment in Streamlit and is native to Dash. A layer of over two million features needs pydeck or a tile URL in Streamlit and Plotly WebGL or a tile URL in Dash, with the same ceiling. More than one replica needs sticky sessions in Streamlit but only stateless workers in Dash. Company single sign-on is built into Streamlit as st.login and comes from the separate dash-auth package in Dash. A working first version is about twenty-five lines in Streamlit and about sixty in Dash. One requirement at a time Requirement Streamlit Dash Map click runs a Python function st_folium returns last_object_clicked Input(layer, clickData) one callback fires Pan and zoom survive an update manual — round-trip the view via session_state automatic — the view is never a callback Output Refresh one panel out of five @st.fragment scopes the rerun to that block native — that is what the callback graph is for Layer over 2 million features pydeck or a tile URL; st_folium will not do it Plotly WebGL or a tile URL; the same ceiling More than one replica sticky sessions required; cache duplicated per pod stateless POSTs — gunicorn --workers 4 Log in with company SSO st.login() is built in from version 1.42 dash_auth.OIDCAuth, a separate package Code for a working first version about 25 lines about 60 lines
The rows where the two genuinely diverge are map view persistence, panel granularity and replica count — the rest is a wash.

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.

Decision tree for choosing between Streamlit and Dash A decision tree. The root asks whether one interaction must update only part of the page. If no, the next question is whether the visible layer is under about 250 thousand features: yes leads to Streamlit with st_folium and a cached loader, no leads to Streamlit with pydeck or a tile URL rather than raw GeoJSON. If yes, the next question is whether the app is multi-user, needs replicas, or runs queries over about two seconds: no leads to Streamlit with st.fragment scoping the rerun in one process, yes leads to Dash with a callback graph and dcc.Store across many replicas. A closing note says the team factor overrides the tree — Streamlit wins when the app is one analyst's tool, Dash wins when someone else maintains it after the analysis is over. Must one interaction update only part of the page? no yes yes no no yes Is the visible layer under about 250k features? Multi-user, replicas, or queries over ~2 seconds? Streamlit st_folium + cached loader ship it this afternoon Streamlit + pydeck or a tile URL, never raw GeoJSON Streamlit + fragment scoped rerun, still one process Dash callback graph plus dcc.Store, N replicas Team factor overrides the tree: Streamlit wins when the app is one analyst's own tool, Dash wins when someone else has to maintain it after the analysis is over.
Interactivity granularity splits the tree first; data volume and replica count decide the leaf.

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

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.