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.

Capturing a drawing into session state and showing it back without changing the map. both halves are requireduser drawsin the componentcomponent returnsGeoJSONuntil the map changesstore insession_stateas a shapely geometryshow viafeature_group_to_addmap itself unchangedStoring it keeps the filter; showing it back tells the user what they filtered by spatialworkflow.io
Change the folium map and the component is rebuilt, taking the drawing with it.

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.

Why an unrestricted st_folium re-runs the app every time the map is panned. a rerun for every pandefault returned_objectsβœ•returns centre, zoom, bounds, clicksβœ•all change on every panβœ•each change re-runs the scriptβœ•five pans, five rerunsrestrictedβœ“returned_objects=["last_active_drawing"]βœ“panning returns nothing newβœ“six pans, no rerunsβœ“the app re-runs on a completed shapeIt feels like a performance problem and it is a configuration one spatialworkflow.io
A rerun counter in the sidebar diagnoses this in two lines.

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.

Three versions of a Streamlit drawing app driven in headless Chromium: which user actions did what the user meant. driven in a browser: did each action work?area on the mapstable map, no keyExample 16 pans, no rerunβœ“yesβœ“yesβœ“yessecond drawingβœ•lostβœ“yesβœ“yesClear area!by accidentβœ•undoneβœ“yestoo-large areaβœ•lostβœ“warnedβœ“warnedstreamlit-folium 0.27.4 and Streamlit 1.64 in headless Chromium; Example 4 spatialworkflow.io
Each fix exposes the next problem: a stable map keeps drawings, so clearing then needs a new key.

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.

Guards a drawn area of interest needs before it becomes a query. a drawing is unbounded user inputvalidate the geometryfreehand polygons self-intersectmeasure the area in a projected CRSdegrees are not an arearefuse above a maximumwith the number, so it can be adjustedoffer a clear buttonor the user is trapped in their filterrunning the query on whatever arrivesa rectangle round EuropeUsers adjust quickly once told the limit, and cannot know it otherwise spatialworkflow.io
The area check turns an unbounded query into a message.

Rerun triggers, circle tools and clearing

  • returned_objects restricts 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 key as well as session_state.pop, or the component hands the old drawing back.
  • last_active_drawing is the most recent shape, not all of them; use all_drawings for several.
  • Freehand polygons can self-intersect. make_valid before 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_folium is heavier than pydeck β€” it is worth it for the Leaflet plugins.
  • The drawn geometry is EPSG:4326; reproject before any distance or area work.

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.