How to Let Users Draw an Area of Interest in a Map App
On this page
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 whenever a rerun builds a different map β including a map that shows the drawing back
- the app re-runs on every pan and zoom, 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="OpenStreetMap")
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 after every pan and zoom, and each one re-runs the app. The tiles are OpenStreetMap's because CARTO's basemaps, folium's cartodbpositron, now draw "API KEY REQUIRED" on every tile without a key.
What you get β from The data used on this page
361 districts; EPSG:4326
Restrict returns, store the drawing, validate, bound
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. Measured in a browser, five pans of a folium map caused five reruns with the default returns; with only last_active_drawing returned, five pans caused none (Example 4).
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 first time a rerun builds a different map β a slider that adds a layer, a filter that changes the data shown. A widget that leaves the map untouched does not clear it: the component keeps the drawing and keeps returning it, which matters in step 6.
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. Switch off the circle tool: Leaflet Draw has no GeoJSON circle, and in the browser a drawn circle came back as null β the user draws, the app re-runs, and nothing changes.
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
The stored shape should be on the map, so the user can see what they filtered by. Adding it to the folium map is the obvious way and the wrong one: the map is then different on the next run, the component is rebuilt, and the user's next drawing is lost β it comes back as null and has to be drawn twice. Pass it as a layer the component can swap in place instead:
group = folium.FeatureGroup(name="area of interest")
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(group)
state = st_folium(draw_map(), feature_group_to_add=group,
returned_objects=["last_active_drawing"])
6. Give them a way to clear it
Popping the stored shape is not enough. The component still holds the last drawing and returns it on the very next run, so the script stores it again: in the browser, the area was back before the page had finished redrawing. Give the component a new key as well, which discards its value and its drawn layers:
st.session_state.setdefault("map_version", 0)
def clear_area():
st.session_state.pop("aoi", None)
st.session_state.map_version += 1
st.sidebar.button("Clear area", on_click=clear_area)
state = st_folium(draw_map(), key=f"draw-{st.session_state.map_version}",
returned_objects=["last_active_drawing"])
A drawing tool with no working clear button traps the user in their own filter.
Drawing an area over UK local authorities
The data used on this page
The "districts" are the 361 local authority districts of the UK, December 2024, from the ONS Open Geography Portal (generalised, clipped to the coastline), saved as districts.gpkg in EPSG:4326 with a code and a name.
import os
import geopandas as gpd
import requests
ONS = ("https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/"
"Local_Authority_Districts_December_2024_Boundaries_UK_BGC/FeatureServer/0/query")
if not os.path.exists("districts.gpkg"):
reply = requests.get(ONS, timeout=120, params={
"where": "1=1", "outFields": "LAD24CD,LAD24NM", "outSR": 4326, "f": "geojson"})
(gpd.read_file(reply.text)
.rename(columns={"LAD24CD": "code", "LAD24NM": "name"})[["code", "name", "geometry"]]
.to_file("districts.gpkg"))
districts = gpd.read_file("districts.gpkg")
print(len(districts), "districts;", districts.crs)
361 districts; EPSG:4326
Example 1 β the complete pattern
%%writefile draw_app.py
import folium
import geopandas as gpd
import streamlit as st
from folium.plugins import Draw
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(centre=(54.0, -2.0), zoom=6):
"""The same map on every run, so the component is never rebuilt."""
m = folium.Map(location=centre, zoom_start=zoom, tiles="OpenStreetMap")
Draw(export=False,
draw_options={"polyline": False, "circlemarker": False, "marker": False,
"circle": False}, # circles come back as null
edit_options={"edit": True, "remove": True}).add_to(m)
return m
def aoi_layer(aoi):
"""The stored area as a layer the component swaps in place."""
group = folium.FeatureGroup(name="area of interest")
if aoi is not None:
folium.GeoJson(aoi.__geo_interface__,
style_function=lambda _: {"color": "#ef4444", "weight": 2,
"fillOpacity": 0.05}).add_to(group)
return group
def clear_area():
st.session_state.pop("aoi", None)
st.session_state.map_version += 1 # a new key discards the component's drawing
districts = load("districts.gpkg")
st.session_state.setdefault("map_version", 0)
st.sidebar.button("Clear area", on_click=clear_area)
state = st_folium(build_map(), feature_group_to_add=aoi_layer(st.session_state.get("aoi")),
key=f"draw-{st.session_state.map_version}", 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")
Overwriting draw_app.py
Three lines differ from the version most tutorials show, and each was found by driving the app in a browser (Example 4): the circle tool is off, the stored area goes in through feature_group_to_add rather than onto the map, and clearing changes the component's key.
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)
# the value st_folium returned for this page's opening view, with returned_objects=["bounds"]
opening = {"bounds": {"_southWest": {"lat": 50.21909462044748, "lng": -9.73388671875},
"_northEast": {"lat": 57.45677122453565, "lng": 5.73486328125}}}
subset, box = bounds_filter(opening, districts)
print(f"{len(subset)} of {len(districts)} districts on screen; box {[round(v, 2) for v in box]}")
358 of 361 districts on screen; box [-9.73, 50.22, 5.73, 57.46]
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. .cx keeps every district whose geometry intersects the box, so a district that is only partly on screen still counts.
Example 3 β pushing the drawn area into a database query
import duckdb
import streamlit as st
@st.cache_resource
def connection():
con = duckdb.connect()
con.install_extension("spatial")
con.load_extension("spatial")
con.execute("create table districts as "
"select code, name, geom from st_read('districts.gpkg')")
return con
@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 code, name, st_asgeojson(geom) as geometry
from districts
where st_intersects(geom, st_geomfromtext(?))
limit ?
""", [aoi_wkt, limit]).df()
aoi_wkt = "POLYGON ((-2.73 53.34, -1.91 53.34, -1.91 53.69, -2.73 53.69, -2.73 53.34))"
frame = query_in_aoi(connection(), aoi_wkt)
print(len(frame), "districts:", ", ".join(sorted(frame["name"])[:6]), "β¦")
23 districts: Blackburn with Darwen, Bolton, Bury, Calderdale, Cheshire East, Cheshire West and Chester β¦
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. The leading underscore on _con tells cache_data not to try to hash the connection.
Example 4 β the pattern tested in a browser
Streamlit's AppTest cannot draw on a Leaflet map, so this drives the real app in headless Chromium with Playwright: six pans, a rectangle, a second rectangle, the Clear button, another rectangle, then one far larger than the limit. It runs Example 1, Example 1 without its key change, and the version most tutorials show β the stored area added to the folium map, and a Clear button that only pops it from session_state:
%%writefile common_app.py
import folium
import geopandas as gpd
import streamlit as st
from folium.plugins import Draw
from shapely.geometry import shape
from streamlit_folium import st_folium
districts = gpd.read_file("districts.gpkg")
st.sidebar.button("Clear area", on_click=lambda: st.session_state.pop("aoi", None))
m = folium.Map(location=(54.0, -2.0), zoom_start=6, tiles="OpenStreetMap")
Draw(export=False, draw_options={"polyline": False, "circlemarker": False,
"marker": False, "circle": False}).add_to(m)
if (aoi := st.session_state.get("aoi")) is not None:
folium.GeoJson(aoi.__geo_interface__).add_to(m) # shown on the map itself
state = st_folium(m, 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"])
if (aoi := st.session_state.get("aoi")) is not None:
st.success(f"{districts.intersects(aoi).sum():,} districts in the drawn area")
else:
st.info("Draw a rectangle or polygon on the map to filter.")
%%writefile counted.py
import os
with open("runs.log", "a") as log: # one line per script run
log.write("run\n")
exec(open(os.environ["APP"]).read())
import re
import socket
import subprocess
import sys
import time
import urllib.request
from playwright.sync_api import sync_playwright
# Example 1 without the key change, to see what that line is for
open("nokey_app.py", "w").write(open("draw_app.py").read().replace(
" st.session_state.map_version += 1 # a new key discards the component's drawing\n", ""))
def runs():
return sum(1 for _ in open("runs.log")) if os.path.exists("runs.log") else 0
def serve(app):
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
port = probe.getsockname()[1]
server = subprocess.Popen(
[sys.executable, "-m", "streamlit", "run", "counted.py", "--server.headless", "true",
"--server.port", str(port), "--browser.gatherUsageStats", "false"],
env=dict(os.environ, APP=app), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
url = f"http://127.0.0.1:{port}"
for _ in range(120):
try:
urllib.request.urlopen(url + "/_stcore/health", timeout=1)
break
except OSError:
time.sleep(0.5)
return server, url
def message(page):
text = " ".join(page.locator('[data-testid="stAlert"]').all_inner_texts())
return re.sub(r"\s+", " ", text).strip()
def drag(page, x0, y0, x1, y1):
box = page.locator("iframe").first.bounding_box()
page.mouse.move(box["x"] + x0, box["y"] + y0)
page.mouse.down()
page.mouse.move(box["x"] + x1, box["y"] + y1, steps=12)
page.mouse.up()
time.sleep(4)
def rectangle(page, *corners):
page.frame_locator("iframe").first.locator(".leaflet-draw-draw-rectangle").click()
time.sleep(0.5)
drag(page, *corners)
def clear(page):
page.get_by_role("button", name="Clear area").click()
time.sleep(5)
for app in ("common_app.py", "nokey_app.py", "draw_app.py"):
if os.path.exists("runs.log"):
os.remove("runs.log")
server, url = serve(app)
try:
with sync_playwright() as playwright:
browser = playwright.chromium.launch()
page = browser.new_page(viewport={"width": 1300, "height": 1000})
page.goto(url)
page.wait_for_selector("iframe", timeout=90_000)
time.sleep(6)
print(app)
start = runs()
for i in range(6): # three there and back
drag(page, 350, 280, 410, 300) if i % 2 == 0 else drag(page, 410, 300, 350, 280)
print(f" 6 pans: {runs() - start} reruns")
for label, act in [("rectangle A", lambda: rectangle(page, 300, 250, 420, 330)),
("rectangle B", lambda: rectangle(page, 180, 330, 260, 400)),
("Clear area", lambda: clear(page)),
("rectangle C", lambda: rectangle(page, 300, 250, 420, 330)),
("huge rectangle", lambda: rectangle(page, 120, 60, 690, 540))]:
act()
print(f" {label:15} β {message(page)}")
browser.close()
finally:
server.terminate()
server.wait(timeout=30)
common_app.py
6 pans: 0 reruns
rectangle A β 51 districts in the drawn area
rectangle B β 51 districts in the drawn area
Clear area β Draw a rectangle or polygon on the map to filter.
rectangle C β 51 districts in the drawn area
huge rectangle β 51 districts in the drawn area
nokey_app.py
6 pans: 0 reruns
rectangle A β 51 districts in a 20,009 kmΒ² area
rectangle B β 4 districts in a 12,222 kmΒ² area
Clear area β 4 districts in a 12,222 kmΒ² area
rectangle C β 51 districts in a 20,009 kmΒ² area
huge rectangle β 572,243 kmΒ² is too large β the maximum is 50,000 kmΒ². 51 districts in a 20,009 kmΒ² area
draw_app.py
6 pans: 0 reruns
rectangle A β 51 districts in a 20,009 kmΒ² area
rectangle B β 4 districts in a 12,222 kmΒ² area
Clear area β Draw a rectangle or polygon on the map to filter.
rectangle C β 51 districts in a 20,009 kmΒ² area
huge rectangle β 572,243 kmΒ² is too large β the maximum is 50,000 kmΒ². 51 districts in a 20,009 kmΒ² area
Read down the columns. The common version loses rectangle B and the huge one: each accepted drawing changes the folium map, the component is rebuilt, and the next drawing comes back empty. Its Clear button works, but only by accident β the rebuild had already thrown the drawing away. Holding the map constant with feature_group_to_add fixes the lost drawings and exposes the second problem: now the component keeps rectangle B, and after Clear the script stores it again. Only with both changes does every step do what the user meant. None of the three re-ran on a pan.
Why the drawing disappears
Why the drawing disappears
The script builds a new folium map on every rerun, and st_folium compares it with the one it is showing. If they are the same, the component is left alone, drawing and all, and it keeps returning that drawing. If anything differs β a layer added, a marker moved, the stored area drawn onto it β the component is rebuilt, and the drawing and its returned value are gone.
So the drawing has to be captured when it is returned and stored somewhere that survives β session_state β and shown back without changing the map, which is what feature_group_to_add is for. The corollary is that the component's memory outlives yours: clearing session_state does not clear the component, and only a new key does.
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 after every pan and zoom, 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.
Rerun triggers, circle tools and clearing
returned_objectsrestricts what triggers a rerun β set it to the minimum.- Turn the circle tool off. A drawn circle is returned as
null. - Clearing needs a new
keyas well assession_state.pop, or the component hands the old drawing back. 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.
- Show the stored shape through
feature_group_to_add, not by adding it to the map, or the next drawing is lost. - 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 filter changed the folium map, and a changed map rebuilds the component. Capture the returned geometry into st.session_state and show it through feature_group_to_add, so the map itself stays the same.
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.