---
title: "Interactive web maps from Python with Folium and GeoPandas .explore()"
description: "Turn GeoDataFrames into shareable Leaflet maps with .explore() and Folium: choropleths, tooltips, clustered markers and basemaps in one HTML file."
url: https://www.technicalgis.com/tutorials/interactive-web-maps-folium/
author: TechnicalGIS Team
publisher: TechnicalGIS (https://www.technicalgis.com/about/)
published: 2026-10-14
updated: 2026-10-14
language: en-GB
---

# Interactive web maps from Python with Folium and GeoPandas .explore()

> Turn GeoDataFrames into shareable Leaflet maps with .explore() and Folium: choropleths, tooltips, clustered markers and basemaps in one HTML file.

- Level: Beginner
- Tested with: Python 3.8
- Source code: [Download scripts](https://www.technicalgis.com/code/interactive-web-maps-folium/)

## Key takeaways

1.  GeoDataFrame.explore() makes an interactive, classified choropleth with tooltips and a legend in one line.
2.  Folium writes a self-contained Leaflet HTML file; the GeoJSON is embedded, so simplify geometries and keep only the columns you need.
3.  Folium and Leaflet expect \[lat, lon\], while Shapely and GeoJSON use (lon, lat). Swapping them is the most common Folium bug.
4.  Use basemaps that need no API key, and always pass attr= for custom tile URLs.

## Before you start

-   Basic GeoPandas (see the GeoPandas getting-started tutorial)
-   Python 3.8+ with geopandas, folium, mapclassify and matplotlib
-   A modern web browser to open the generated HTML

A static PNG is perfect for a report, but colleagues and clients increasingly expect to **pan, zoom and click**. [Folium](https://python-visualization.github.io/folium/) writes [Leaflet.js](https://leafletjs.com/) maps from Python. The output is a single self-contained HTML file you can email, drop on a web server or embed in a page, as we do below. No GIS server or JavaScript knowledge is required.

In this tutorial you will:

1.  make an interactive choropleth in **one line** with `GeoDataFrame.explore()`;
2.  build a richer map with the Folium API: switchable basemaps, a styled choropleth with hover highlight and tooltips, **clustered markers** with popups, an optional layer and plugins (fullscreen, minimap and measure);
3.  learn the practical tricks for keeping the HTML small and fast.

The code is in [`folium_maps.py`](https://www.technicalgis.com/code/interactive-web-maps-folium/). The screenshots were taken from its output with Playwright, and the live map near the end is the exact file the script wrote.

## Step 1: prepare the data

We use [Natural Earth](https://www.naturalearthdata.com/) 1:50m countries and populated places. We compute area in an equal-area CRS (EPSG:3035, ETRS89 LAEA Europe), as covered in the [GeoPandas tutorial](https://www.technicalgis.com/tutorials/geopandas-getting-started/):

`folium_maps.py`

```python
countries = gpd.read_file(fetch("ne_50m_admin_0_countries.zip"))
countries = countries[["NAME", "CONTINENT", "POP_EST", "GDP_MD", "geometry"]].rename(
    columns={"NAME": "name", "CONTINENT": "continent", "POP_EST": "population", "GDP_MD": "gdp_musd"})
europe = countries[countries["continent"] == "Europe"].copy()
europe["area_km2"] = europe.to_crs("EPSG:3035").area / 1e6
europe["density"] = (europe["population"] / europe["area_km2"]).round(1)
europe["gdp_per_capita"] = (europe["gdp_musd"] * 1e6 / europe["population"]).round(0)
europe = europe[europe["population"] > 0]

places = gpd.read_file(fetch("ne_50m_populated_places.zip"))[["NAME", "ADM0NAME", "POP_MAX", "geometry"]]
places = places.rename(columns={"NAME": "city", "ADM0NAME": "country", "POP_MAX": "population"})
eu_places = gpd.sjoin(places, europe[["geometry"]], predicate="within").drop(columns="index_right")
```

`Output`

```text
50 European countries; 209 populated places
    name  population  area_km2  density
  Monaco     38964.0      12.0   3133.0
   Malta    502653.0     274.0   1836.0
Guernsey     62792.0      48.0   1302.0
 Vatican       825.0       1.0   1166.0
  Jersey    107800.0     127.0    851.0
```

Before mapping, there are two preparation steps:

```python
# Clip to a European window: Russia crosses the antimeridian and would otherwise
# draw a band across the whole map.
europe = europe.clip((-25, 34, 50, 72))
# Simplify geometries (~1 km) to keep the HTML small: the browser draws every vertex.
europe["geometry"] = europe.to_crs("EPSG:3035").simplify(1000).to_crs("EPSG:4326")
```

Folium embeds the GeoJSON **inside the HTML file**, so every vertex costs bytes and rendering time. Simplifying to a 1 km tolerance in a metric CRS makes no visible difference at continental zoom levels.

## Step 2: the one-liner, `.explore()`

GeoPandas has Folium built in. `explore()` handles classification (via mapclassify), the colour map, tooltips and the legend:

```python
ESRI_GRAY = "https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}"
ESRI_GRAY_ATTR = "Tiles © Esri — Esri, DeLorme, NAVTEQ"

m1 = europe.explore(column="density", scheme="Quantiles", k=5, cmap="YlGnBu",
                    tooltip=["name", "population", "density"],
                    legend_kwds={"caption": "People per km²", "colorbar": False, "fmt": "{:.0f}"},
                    tiles=ESRI_GRAY, attr=ESRI_GRAY_ATTR, location=[54, 15], zoom_start=4)
m1.save("output/explore_density.html")
```

In a Jupyter notebook, just put `m1` on the last line of a cell to display the map inline.

![Leaflet map of Europe on a light grey basemap with countries shaded in five quantile classes of population density, from pale yellow (4–46 per km²) to dark navy (244–3,133 per km²). The UK, Netherlands and Belgium are darkest; Scandinavia and Russia palest.](https://www.technicalgis.com/_astro/01-explore-density.DBmeSUyW_Z1drDx5.webp)

_Population density in five quantile classes, produced by a single explore() call. Setting colorbar=False gives a class legend instead of a continuous bar, which is much easier to read when the range is skewed by microstates._

## Step 3: a hand-crafted Folium map

For full control, build the map yourself. Start with an empty map and add several base layers. Only one base layer is shown at a time, and the layer control lets users switch between them:

```python
import branca.colormap as cm
import folium
from folium import plugins

m = folium.Map(location=[54, 12], zoom_start=4, tiles=None, control_scale=True)
folium.TileLayer("OpenStreetMap", name="OpenStreetMap").add_to(m)
folium.TileLayer(ESRI_GRAY, attr=ESRI_GRAY_ATTR, name="Light grey (Esri)").add_to(m)
folium.TileLayer(
    tiles="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
    attr="Tiles © Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community",
    name="Esri World Imagery").add_to(m)
```

### A styled choropleth with tooltips

`folium.GeoJson` takes a `style_function` that is called for each feature. We use a **branca** colour map, clipped to the 5th–95th percentile so that outliers don’t wash out the scale, and a `highlight_function` for hover feedback:

```python
colormap = cm.LinearColormap(["#f7fcf0", "#7bccc4", "#0868ac"],
                             vmin=float(europe.gdp_per_capita.quantile(0.05)),
                             vmax=float(europe.gdp_per_capita.quantile(0.95)),
                             caption="GDP per capita (USD, Natural Earth estimates)")

folium.GeoJson(
    europe[["name", "population", "density", "gdp_per_capita", "geometry"]],
    name="GDP per capita",
    style_function=lambda f: {"fillColor": colormap(f["properties"]["gdp_per_capita"]),
                              "color": "#475569", "weight": 0.6, "fillOpacity": 0.7},
    highlight_function=lambda f: {"weight": 2.5, "color": "#e8590c", "fillOpacity": 0.85},
    tooltip=folium.GeoJsonTooltip(fields=["name", "gdp_per_capita", "density"],
                                  aliases=["Country", "GDP per capita (USD)", "People per km²"],
                                  localize=True, sticky=True),
).add_to(m)
colormap.add_to(m)
```

Select only the columns you need before passing a GeoDataFrame to `GeoJson`. Every attribute is serialised into the HTML file.

![Zoomed map of central Europe with Germany outlined in orange and a tooltip reading Country Germany, GDP per capita 46,445 USD, People per km² 232.6.](https://www.technicalgis.com/_astro/03-folium-tooltip.iZmXjefc_27zMRF.webp)

_Hovering highlights the country in orange and shows a formatted tooltip. localize=True adds the thousands separators._

### Clustered markers with popups

Plotting 209 individual markers clutters a continental map. `MarkerCluster` groups nearby markers into numbered bubbles that split apart as you zoom in:

```python
cluster = plugins.MarkerCluster(name="Cities (clustered)").add_to(m)
for _, r in eu_places.iterrows():
    folium.Marker(
        location=[r.geometry.y, r.geometry.x],          # Folium wants [lat, lon]!
        tooltip=r["city"],
        popup=folium.Popup(f"<b>{r['city']}</b><br>{r['country']}<br>Population: {r['population']:,}",
                           max_width=220),
        icon=folium.Icon(color="darkblue", icon="info-sign"),
    ).add_to(cluster)
```

### An optional layer and plugins

A `FeatureGroup` created with `show=False` appears unticked in the layer control. Here it holds circle markers scaled by population for cities over 2 million. Plugins add common web-map tools in one line each:

```python
big = eu_places[eu_places["population"] >= 2_000_000]
fg = folium.FeatureGroup(name="Cities ≥ 2 million", show=False).add_to(m)
for _, r in big.iterrows():
    folium.CircleMarker([r.geometry.y, r.geometry.x], radius=4 + r["population"] / 1.5e6,
                        color="#e8590c", fill=True, fill_opacity=0.6, weight=1,
                        tooltip=f"{r['city']}: {r['population']:,}").add_to(fg)

plugins.Fullscreen().add_to(m)
plugins.MiniMap(toggle_display=True).add_to(m)
plugins.MeasureControl(primary_length_unit="kilometers").add_to(m)
folium.LayerControl(collapsed=False).add_to(m)      # add this last so it sees every layer
m.save("output/europe_folium.html")
```

`Output`

```text
europe_folium.html       737 KB
explore_density.html     431 KB
```

![The finished Folium map over Esri satellite imagery: European countries tinted by GDP per capita, numbered green and yellow marker clusters, a gradient legend at the top, a layer switcher listing three basemaps and three overlays, and a minimap in the bottom-right corner.](https://www.technicalgis.com/_astro/02-folium-overview.CSGuNBzV_uDrjm.webp)

_The finished map with the satellite base layer: a choropleth overlay, marker clusters, a layer control, a minimap and fullscreen/measure buttons._

![Satellite view of London and the Thames estuary at zoom level 9 with an open popup reading London, United Kingdom, Population 8,567,000, above an orange population circle.](https://www.technicalgis.com/_astro/04-folium-popup-imagery.BbagqrJu_ZUeAwB.webp)

_Zoomed into London with the choropleth switched off and the 'Cities ≥ 2 million' layer switched on. Clicking a marker opens its HTML popup._

## Try the live map

This is the exact `europe_folium.html` written by the script. Pan, zoom, switch layers and click the clusters:

Live Folium output. It loads Leaflet from public CDNs and tiles from OpenStreetMap and Esri. [Open full screen ↗](https://www.technicalgis.com/maps/interactive-web-maps-folium/europe_folium.html)

## Embedding and sharing tips

-   **Embed** the HTML with an `<iframe>`, as on this page, or use `m._repr_html_()` / `m.get_root().render()` if you need the markup as a string.
-   **Big data?** Folium embeds everything in the page, and a few thousand polygons is the practical limit. Beyond that, simplify harder, aggregate, or switch to vector tiles (PMTiles), `lonboard` or a tile server.
-   **Offline use:** the HTML loads Leaflet and its plugins from CDNs and fetches tiles live, so it needs an internet connection to display.
-   **Attribution** is shown automatically for built-in tile sets. Always pass `attr=` for custom tile URLs.

## Next steps

-   Map the results of the [OSMnx accessibility tutorial](https://www.technicalgis.com/tutorials/street-networks-osmnx/) with `edges.explore(column="walk_min")`.
-   Add a time slider with `plugins.TimestampedGeoJson` to animate the [Lake Mead shoreline](https://www.technicalgis.com/tutorials/sentinel-2-stac-lake-mead/).
-   Prefer a static figure for print? See [GeoPandas choropleths](https://www.technicalgis.com/tutorials/geopandas-getting-started/).

## Data and licences

-   **Natural Earth** 1:50m Admin 0 countries and populated places: public domain, [naturalearthdata.com](https://www.naturalearthdata.com/). Population and GDP are Natural Earth’s estimates.
-   **Basemaps**: © [OpenStreetMap contributors](https://www.openstreetmap.org/copyright) (ODbL); Esri World Light Gray Canvas (Esri, DeLorme, NAVTEQ); Esri World Imagery (Esri, Maxar, Earthstar Geographics and the GIS User Community).
-   Code: [`folium_maps.py`](https://www.technicalgis.com/code/interactive-web-maps-folium/), tested with Python 3.8.10, folium 0.18.0, geopandas 0.13.2 and mapclassify 2.6.0.

## Key terms

- **Folium**: A Python library that writes Leaflet.js web maps as HTML.
- **Leaflet**: A lightweight open-source JavaScript library for interactive web maps.
- **Basemap (tile layer)**: Background map imagery served as small XYZ tiles, such as OpenStreetMap or Esri World Imagery.
- **Marker clustering**: Grouping nearby markers into numbered bubbles that split apart as you zoom in.

## Data for this tutorial

-   [Natural Earth](https://www.technicalgis.com/data-sources/#src-natural-earth) · Public-domain countries and populated places
-   [OpenStreetMap](https://www.technicalgis.com/data-sources/#src-openstreetmap) · ODbL 1.0

Licences and formats for each source are listed in the [GIS data sources catalogue](https://www.technicalgis.com/data-sources/).

---

Source: https://www.technicalgis.com/tutorials/interactive-web-maps-folium/
