How to Let Users Draw an Area of Interest in a Map App
Problem statement
The most useful control in a spatial app is not a dropdown โ it is the map. "Show me the data in this area" is a question a rectangle answers better than any list of regions.
Implementing it in Streamlit runs into the framework's model immediately:
- the drawing disappears when any other widget changes, because the map component is rebuilt
- the app re-runs on every map movement, not just when a shape is completed
- the returned geometry is in the browser's coordinates and needs interpreting
- an unbounded polygon can select the entire dataset
Each has a fix, and together they are the difference between a demo and a control people use.
Quick answer
streamlit-folium with the Draw plugin, storing the result in session_state:
import folium
from folium.plugins import Draw
import streamlit as st
from streamlit_folium import st_folium
from shapely.geometry import shape
def draw_map(centre=(54.0, -2.0), zoom=6):
m = folium.Map(location=centre, zoom_start=zoom, tiles="cartodbpositron")
Draw(export=False,
draw_options={"polyline": False, "circlemarker": False,
"marker": False, "circle": False},
edit_options={"edit": True}).add_to(m)
return m
state = st_folium(draw_map(), height=560, width=None,
returned_objects=["last_active_drawing"])
if state and state.get("last_active_drawing"):
st.session_state.aoi = shape(state["last_active_drawing"]["geometry"])
aoi = st.session_state.get("aoi")
subset = districts[districts.intersects(aoi)] if aoi is not None else districts
st.caption(f"{len(subset):,} of {len(districts):,} districts"
+ (" in the drawn area" if aoi is not None else " (draw an area to filter)"))
returned_objects=["last_active_drawing"] is the line that makes it usable: without it, st_folium returns the map's state on every pan and the app re-runs continuously.
Step-by-step solution
1. Restrict what the component returns
st_folium returns a dictionary of map state, and any returned value triggers a Streamlit rerun. By default that includes the bounds and the centre, so panning the map re-runs the script.
returned_objects=["last_active_drawing"] # only what you use
Naming only what the app reads is the single most important line in this pattern.
2. Store the drawing in session_state
The map component is rebuilt on every rerun, so the drawing exists only in the value returned at the moment it was made. Persist it immediately:
if state and state.get("last_active_drawing"):
st.session_state.aoi = shape(state["last_active_drawing"]["geometry"])
Without this the shape vanishes the moment the user touches a slider โ which is the most common complaint about drawing tools in Streamlit.
3. Convert the GeoJSON into a geometry, and validate it
The plugin returns GeoJSON in EPSG:4326. shapely.geometry.shape() converts it, and a self-intersecting freehand polygon is entirely possible:
from shapely.geometry import shape
from shapely.validation import make_valid
geometry = shape(feature["geometry"])
if not geometry.is_valid:
geometry = make_valid(geometry)
4. Bound what a drawing may select
A user can draw a rectangle around the world. Guard on area before running the query:
MAX_AOI_KM2 = 50_000
def check_area(geometry_4326, crs_for_area="EPSG:3035"):
import geopandas as gpd
area_km2 = (gpd.GeoSeries([geometry_4326], crs=4326)
.to_crs(crs_for_area).area.iloc[0] / 1e6)
if area_km2 > MAX_AOI_KM2:
st.warning(f"That area is {area_km2:,.0f} kmยฒ; the maximum is "
f"{MAX_AOI_KM2:,} kmยฒ. Draw something smaller.")
return False
return True
Measure the area in a projected CRS. Degrees are not an area, and a bounding box in degrees is a different size at every latitude.
5. Show the drawing back to the user
After a rerun the map is new and the drawing is not on it. Re-add the stored shape so the user can see what they filtered by:
m = draw_map()
if (aoi := st.session_state.get("aoi")) is not None:
folium.GeoJson(aoi.__geo_interface__,
style_function=lambda _: {"color": "#ef4444", "weight": 2,
"fillOpacity": 0.05}).add_to(m)
6. Give them a way to clear it
if st.sidebar.button("Clear area", on_click=lambda: st.session_state.pop("aoi", None)):
st.rerun()
A drawing tool with no clear button traps the user in their own filter โ and "how do I get all the data back?" is the second most common complaint.
Code examples
Example 1 โ the complete pattern
import folium
from folium.plugins import Draw
import geopandas as gpd
import streamlit as st
from shapely.geometry import shape
from shapely.validation import make_valid
from streamlit_folium import st_folium
MAX_AOI_KM2 = 50_000
AREA_CRS = "EPSG:3035"
@st.cache_data
def load(path):
return gpd.read_file(path).to_crs(4326)
def aoi_area_km2(geometry) -> float:
return float(gpd.GeoSeries([geometry], crs=4326)
.to_crs(AREA_CRS).area.iloc[0] / 1e6)
def build_map(existing_aoi=None, centre=(54.0, -2.0), zoom=6):
m = folium.Map(location=centre, zoom_start=zoom, tiles="cartodbpositron")
Draw(export=False,
draw_options={"polyline": False, "circlemarker": False, "marker": False},
edit_options={"edit": True, "remove": True}).add_to(m)
if existing_aoi is not None:
folium.GeoJson(existing_aoi.__geo_interface__,
name="area of interest",
style_function=lambda _: {"color": "#ef4444", "weight": 2,
"fillOpacity": 0.05}).add_to(m)
return m
districts = load("districts.gpkg")
st.sidebar.button("Clear area",
on_click=lambda: st.session_state.pop("aoi", None))
state = st_folium(build_map(st.session_state.get("aoi")),
height=560, width=None,
returned_objects=["last_active_drawing"])
if state and state.get("last_active_drawing"):
geometry = shape(state["last_active_drawing"]["geometry"])
if not geometry.is_valid:
geometry = make_valid(geometry)
area = aoi_area_km2(geometry)
if area > MAX_AOI_KM2:
st.warning(f"{area:,.0f} kmยฒ is too large โ the maximum is "
f"{MAX_AOI_KM2:,} kmยฒ.")
else:
st.session_state.aoi = geometry
aoi = st.session_state.get("aoi")
if aoi is not None:
subset = districts[districts.intersects(aoi)]
st.success(f"{len(subset):,} districts in a "
f"{aoi_area_km2(aoi):,.0f} kmยฒ area")
else:
subset = districts
st.info("Draw a rectangle or polygon on the map to filter.")
st.download_button("Download selection",
subset.drop(columns="geometry").to_csv(index=False),
"selection.csv", "text/csv")
Example 2 โ using the map's bounds instead of a drawing
def bounds_filter(state, gdf):
"""Sometimes 'what is on screen' is the filter people want."""
bounds = (state or {}).get("bounds")
if not bounds:
return gdf, None
south = bounds["_southWest"]["lat"]
west = bounds["_southWest"]["lng"]
north = bounds["_northEast"]["lat"]
east = bounds["_northEast"]["lng"]
subset = gdf.cx[west:east, south:north]
return subset, (west, south, east, north)
Filtering by the viewport requires bounds in returned_objects, which means every pan re-runs the app. That is acceptable when the filter is cheap and unacceptable when it is not โ which is why an explicit drawing, applied once, is usually the better control.
Example 3 โ pushing the drawn area into a database query
import streamlit as st
@st.cache_data(max_entries=32)
def query_in_aoi(_con, aoi_wkt: str, limit: int = 5000):
"""The drawn polygon becomes a spatial predicate, not a Python filter."""
return _con.execute("""
select name, region, rate, st_asgeojson(geom) as geometry
from districts
where st_intersects(geom, st_geomfromtext(?))
limit ?
""", [aoi_wkt, limit]).df()
if (aoi := st.session_state.get("aoi")) is not None:
frame = query_in_aoi(connection(), aoi.wkt)
Passing the WKT rather than the geometry object gives cache_data something hashable to key on, and it is the same string the database needs โ so the cache key and the query argument are the same value.
Explanation
Why the drawing disappears
Streamlit rebuilds the component on every rerun. The folium map that carried the drawing no longer exists; a new one has been created from the same code.
The returned value is the only moment the drawing is visible to Python, so it has to be captured then and stored somewhere that survives โ session_state. Re-adding it to the map afterwards is what makes it visible again, and both halves are needed.
Why returned_objects is not optional
Any component that returns a value triggers a rerun. st_folium returns the map's centre, zoom, bounds, last click and drawings by default, and all of those change while panning.
The result is an app that re-runs continuously while the user moves the map, which feels like a performance problem and is a configuration one. Restricting the returned objects to what the app reads breaks the loop.
Why the area guard matters
A drawn polygon is unbounded user input. A rectangle around Europe selects a large fraction of most national datasets, and the app will attempt it.
Checking the area in a projected CRS before running the query converts that into a message. It also teaches the constraint: users adjust quickly once told, and they cannot know the limit otherwise.
Why the viewport is a worse control than a drawing
Filtering by the current view is appealing โ no drawing needed, always current โ and it couples the filter to every pan and zoom. With bounds in returned_objects, every map movement re-runs the app.
An explicit drawing separates navigation from filtering: the user moves the map freely, then applies a filter once. That is fewer reruns and a clearer interaction, which is why it is worth the extra control.
Edge cases or notes
returned_objectsrestricts what triggers a rerun โ set it to the minimum.last_active_drawingis the most recent shape, not all of them; useall_drawingsfor several.- Freehand polygons can self-intersect.
make_validbefore using them. - Measure the area in a projected CRS, never in degrees.
- Re-add the stored shape to each rebuilt map, or the user cannot see their filter.
- A clear button is required, or the user is trapped in their own selection.
st_foliumis heavier than pydeck โ it is worth it for the Leaflet plugins.- The drawn geometry is EPSG:4326; reproject before any distance or area work.
Internal links
- How to build a Streamlit app with an interactive map โ the app this control sits in
- Reruns and state explained: why your map app redraws everything โ why the drawing vanishes
- How to add filters and widgets that drive a map โ the other controls
- Fixing a map that resets or disappears on every interaction โ the same root cause
- How to make interactive maps with folium โ the map component
- How to select features by location โ the spatial predicate
- How to add a download button for filtered spatial data โ what to do with the selection
- How to calculate area and distance in GeoPandas โ the area guard
FAQ
Why does my drawing disappear when I change a filter?
Because the map component is rebuilt on every rerun. Capture the returned geometry into st.session_state and re-add it to the map you build next time.
Why does my app re-run when I pan the map?
st_folium returns map state by default, and any returned value triggers a rerun. Set returned_objects=["last_active_drawing"].
How do I use the drawn shape as a filter?
Convert it with shapely.geometry.shape() and use intersects or within โ or pass its WKT into a database query as a spatial predicate.
Should I limit how large an area can be drawn?
Yes. A rectangle around Europe selects most of a national dataset. Check the area in a projected CRS and refuse with a message.
Can I filter by the current map view instead?
You can, but it requires bounds in returned_objects, so every pan re-runs the app. An explicit drawing separates navigation from filtering.
folium or pydeck for drawing?
folium, through the Leaflet Draw plugin. pydeck has no equivalent built in, and this is the case where folium's heavier component is worth it.