How to Create Interactive Maps in Python with Folium

On this page

Problem statement

Folium turns points and polygons from Python into a Leaflet web map that people can pan, zoom and click, with popups and tooltips from your attributes, which is the quickest way to:

  • show survey points or field assets
  • share project boundaries with a client
  • display GeoJSON layers interactively
  • deliver a lightweight web map as an HTML file

The work is in the order of coordinates, the CRS and what goes into each popup.

Quick answer

Folium lets you create Leaflet-based interactive maps in Python. You can center a map on coordinates, add markers, load GeoJSON or GeoPandas data, add popups and tooltips, and save the result as an HTML file.

import folium

m = folium.Map(location=[39.7530, -104.9999], zoom_start=15)   # [lat, lon]
folium.Marker(
    location=[39.7530, -104.9999],
    popup="Denver Union Station (Amtrak DEN)"
).add_to(m)

m.save("interactive-map.html")

Everything on this page runs against real Colorado data β€” the Census Bureau's county boundaries and the 20 Amtrak stops in the state from the Bureau of Transportation Statistics β€” and every saved map is then opened in headless Chromium to count what Leaflet actually drew.

Install, markers, GeoJSON layers, controls, save

The layers that make up a Folium map, from base tiles to layer control. how a Folium map is assembledLayerControl()added lasttoggles everythingMarkers / popupspoints of interesttopGeoJson layeryour GeoDataFramemiddleBase tilesOpenStreetMapbottomfolium.Map(location, zoom)the canvasfirstLayerControl only sees layers that already exist β€” add it last spatialworkflow.io
Order matters twice: for drawing order, and for what the layer control can find.

1. Install Folium and supporting libraries

Install Folium and the libraries commonly used to prepare GIS data before mapping:

pip install folium geopandas pandas

Check that Folium is available:

import folium
print(folium.__version__)
0.20.0

If you plan to work with shapefiles or GeoJSON, GeoPandas is usually the easiest way to prepare data before adding it to a map.

2. Create a basic interactive map

Create a minimal map centered on a city:

import folium

# Center on Denver, Colorado
m = folium.Map(location=[39.7392, -104.9903], zoom_start=11)

m.save("denver-map.html")

Open denver-map.html in a browser to view the map.

3. Add markers for point locations

For a single location, add a marker with a popup and tooltip:

import folium

m = folium.Map(location=[39.7392, -104.9903], zoom_start=11)

folium.Marker(
    location=[39.7530, -104.9999],
    popup="Denver Union Station",
    tooltip="Click for details"
).add_to(m)

m.save("denver-station-map.html")

For multiple points, loop through your data.

From a Python list

import folium

stations = [
    {"name": "Denver", "lat": 39.7530, "lon": -104.9999},
    {"name": "Fraser-Winter Park", "lat": 39.9458, "lon": -105.8172},
    {"name": "Glenwood Springs", "lat": 39.5479, "lon": -107.3232},
]

m = folium.Map(location=[39.7, -106.2], zoom_start=8)

for station in stations:
    folium.Marker(
        location=[station["lat"], station["lon"]],
        popup=station["name"],
        icon=folium.Icon(color="blue", icon="info-sign")
    ).add_to(m)

m.save("station-markers.html")

From a pandas DataFrame

The BTS station layer carries lat and lon as ordinary columns, and StnType says whether a stop is served by train or only by an Amtrak Thruway bus:

import pandas as pd
import folium

df = pd.DataFrame(gpd.read_file("data/colorado_stations.geojson").drop(columns="geometry"))
print(df[["Code", "StationName", "StnType", "lat", "lon"]].head(4).to_string(index=False))

m = folium.Map(location=[39.0, -105.5], zoom_start=7)

for _, row in df.iterrows():
    folium.Marker(
        location=[row["lat"], row["lon"]],
        popup=f"{row['StationName']} ({row['StnType']})",
        tooltip=row["Code"],
        icon=folium.Icon(color="green" if row["StnType"] == "TRAIN" else "red")
    ).add_to(m)

m.save("station-types.html")
print(df["StnType"].value_counts().to_dict())
Code       StationName StnType       lat         lon
 ANT        Almont, CO     BUS 38.665671 -106.845967
 BUV   Buena Vista, CO     BUS 38.842897 -106.131059
 CBC Crested Butte, CO     BUS 38.870357 -106.980905
 DEN        Denver, CO   TRAIN 39.752959 -104.999929
{'BUS': 10, 'TRAIN': 10}

4. Add GeoJSON polygon or line layers

Folium can display GeoJSON directly, which makes it useful for boundaries, service areas, routes, or other vector layers.

import folium

m = folium.Map(location=[39.0, -105.5], zoom_start=7)

folium.GeoJson(
    "data/colorado_counties.geojson",
    name="County boundaries",
    style_function=lambda feature: {
        "fillColor": "#3186cc",
        "color": "black",
        "weight": 1,
        "fillOpacity": 0.3,
    },
    tooltip=folium.GeoJsonTooltip(fields=["NAME"], aliases=["County:"])
).add_to(m)

folium.LayerControl().add_to(m)
m.save("county-boundaries.html")

This pattern works well when you already have a GeoJSON file in longitude/latitude and want to style it and show attributes in a tooltip or popup.

5. Use GeoPandas data with Folium

Web maps work in latitude and longitude. Many GIS files are stored in other coordinate reference systems β€” the county file here is a shapefile in UTM zone 13N (EPSG:26913), the way a Colorado project would usually keep it.

The GeoPandas to Folium pipeline

GeoPandas to FoliumClean inGeoPandasReproject toEPSG:4326Selectattributesfolium.GeoJsonSave HTMLspatialworkflow.io
Preparing data in GeoPandas and displaying it with Folium.
import geopandas as gpd
import folium

# Read a shapefile in a projected CRS
gdf = gpd.read_file("data/colorado_counties_utm.shp")
print(gdf.crs)

# Reproject to WGS84 for Folium
gdf = gdf.to_crs(epsg=4326)

# Keep only columns needed for interaction
gdf = gdf[["GEOID", "NAME", "geometry"]]

# Create map centered on data using total bounds
minx, miny, maxx, maxy = gdf.total_bounds
center = [(miny + maxy) / 2, (minx + maxx) / 2]
print("center:", [round(float(c), 3) for c in center])
m = folium.Map(location=center, zoom_start=7)

folium.GeoJson(
    gdf,
    name="Counties",
    style_function=lambda feature: {
        "fillColor": "#ff7800",
        "color": "#333333",
        "weight": 2,
        "fillOpacity": 0.4,
    },
    tooltip=folium.GeoJsonTooltip(
        fields=["GEOID", "NAME"],
        aliases=["FIPS:", "County:"]
    )
).add_to(m)

m.save("county-geopandas.html")
EPSG:26913
center: [38.998, -105.551]

What skipping those steps actually does

Each variant below leaves out one step. The counts come from the browser check further down, which opens each saved map in Chromium and counts the county outlines Leaflet drew inside the visible map:

utm = gpd.read_file("data/colorado_counties_utm.shp")
view = dict(location=[39.0, -105.5], zoom_start=7)

m = folium.Map(**view)                                 # 1. a projected GeoDataFrame, not reprojected
folium.GeoJson(utm[["GEOID", "NAME", "geometry"]]).add_to(m)
m.save("skip-reproject-gdf.html")

utm.to_file("data/colorado_counties_utm.geojson")      # 2. the same data as a projected GeoJSON file
m = folium.Map(**view)
folium.GeoJson("data/colorado_counties_utm.geojson").add_to(m)
m.save("skip-reproject-file.html")

minx, miny, maxx, maxy = utm.total_bounds               # 3. the centre taken from projected bounds
m = folium.Map(location=[(miny + maxy) / 2, (minx + maxx) / 2], zoom_start=7)
folium.GeoJson(utm[["GEOID", "NAME", "geometry"]]).add_to(m)
m.save("skip-reproject-center.html")
print("centre from projected bounds:", [round((miny + maxy) / 2), round((minx + maxx) / 2)])

stations = gpd.read_file("data/colorado_stations.geojson")
for label, frame in [("4. all columns", stations),
                     ("5. no CRS", stations[["Code", "geometry"]].set_crs(None, allow_override=True))]:
    try:
        folium.GeoJson(frame).add_to(folium.Map(**view))
        print(label, "-> ok")
    except Exception as exc:
        print(label, "->", type(exc).__name__ + ":", exc)
centre from projected bounds: [4320401, 451612]
4. all columns -> TypeError: Object of type Timestamp is not JSON serializable
5. no CRS -> ValueError: Cannot transform naive geometries.  Please set a crs on the object first.

Two of these fail loudly, one works, and two fail silently. A GeoDataFrame with a CRS is reprojected by Folium itself β€” folium.GeoJson calls to_crs("EPSG:4326") on anything that has the method β€” so variant 1 draws correctly. A GeoJSON file gets no such help: variant 2 embeds UTM metres as if they were degrees, and none of the 64 counties is inside the visible map. Variant 3 draws the right layer on a map centred at "latitude" 4,320,401, which Leaflet opens without an error, showing ten tiles of somewhere else and no counties. The column step turns out to matter for a reason other than tidiness: the station layer's DateModif is a datetime, and a Timestamp cannot go into the GeoJSON Folium embeds. So: reproject yourself anyway (it is the only way total_bounds and your centre are in degrees), and pass only the columns you want to show.

6. Add layer controls and base maps

You can add multiple tile layers and overlays so users can switch basemaps or turn layers on and off.

import folium

def station_map(extra_basemaps_shown: bool, path: str):
    m = folium.Map(location=[39.0, -105.5], zoom_start=7, tiles="OpenStreetMap")

    folium.TileLayer("CartoDB positron", name="Light basemap", show=extra_basemaps_shown).add_to(m)
    folium.TileLayer("CartoDB dark_matter", name="Dark basemap", show=extra_basemaps_shown).add_to(m)

    for kind, colour in [("TRAIN", "green"), ("BUS", "red")]:
        group = folium.FeatureGroup(name=f"Amtrak {kind.lower()} stops")
        for _, row in df[df["StnType"] == kind].iterrows():
            folium.Marker([row["lat"], row["lon"]], popup=row["StationName"],
                          icon=folium.Icon(color=colour)).add_to(group)
        group.add_to(m)

    folium.LayerControl().add_to(m)
    m.save(path)
    return m

station_map(True, "layer-control-default.html")      # what TileLayer does by default
m = station_map(False, "layer-control-map.html")

This is useful when you need one output map that supports several layers or display options. The show=False matters more than it looks. Without it, every TileLayer is switched on when the page opens: the browser check below finds the default version downloading 60 tiles β€” 20 for each basemap β€” and showing the last one added, the dark basemap, rather than the OpenStreetMap map that tiles= asked for.

7. Save and share the map

Folium outputs an HTML map file you can open and share easily:

m.save("final-map.html")

You can then:

  • open it directly in a browser
  • send the HTML file to another user
  • include it in internal documentation or reporting workflows

Colorado counties and Amtrak stations, checked in a browser

The data used on this page

The Census Bureau's 2024 cartographic county boundaries (1:500,000) β€” Colorado's 64 counties as GeoJSON in longitude/latitude and as a shapefile in UTM 13N, plus the full national file for the size test β€” and the Bureau of Transportation Statistics' NTAD Amtrak stations for Colorado.

import io
import os
import zipfile

import geopandas as gpd
import requests

os.makedirs("data", exist_ok=True)
if not os.path.exists("census/cb_2024_us_county_500k.shp"):
    reply = requests.get("https://www2.census.gov/geo/tiger/GENZ2024/shp/cb_2024_us_county_500k.zip", timeout=300)
    reply.raise_for_status()
    zipfile.ZipFile(io.BytesIO(reply.content)).extractall("census")
us = gpd.read_file("census/cb_2024_us_county_500k.shp")
colorado = us[us["STUSPS"] == "CO"][["GEOID", "NAME", "geometry"]]
colorado.to_crs(4326).to_file("data/colorado_counties.geojson")
colorado.to_crs(26913).to_file("data/colorado_counties_utm.shp")

if not os.path.exists("data/colorado_stations.geojson"):
    reply = requests.get("https://services.arcgis.com/xOi1kZaI0eWDREZv/arcgis/rest/services/"
                         "NTAD_Amtrak_Stations/FeatureServer/0/query",
                         params={"where": "State='CO'", "outFields": "*", "f": "geojson"}, timeout=120)
    reply.raise_for_status()
    open("data/colorado_stations.geojson", "wb").write(reply.content)

for name in ("colorado_counties.geojson", "colorado_counties_utm.shp", "colorado_stations.geojson"):
    layer = gpd.read_file("data/" + name)
    print(f"{name:27} {len(layer):>3} features  {layer.crs}")
print(f"{'all US counties':27} {len(us):>5,} features")
colorado_counties.geojson    64 features  EPSG:4326
colorado_counties_utm.shp    64 features  EPSG:26913
colorado_stations.geojson    20 features  EPSG:4326
all US counties             3,235 features

Check a saved map in a real browser

A Folium map that saves without error can still be empty. This opens each file in headless Chromium through Playwright, waits for the tiles, and counts what Leaflet put on the page β€” and how much of it is inside the visible map:

from pathlib import Path
from playwright.sync_api import sync_playwright

COUNT = """() => {
  const map = document.querySelector('.leaflet-container');
  if (!map || !map.querySelector('.leaflet-map-pane')) return [0, 0, 0, 0, 0, ''];
  const box = map.getBoundingClientRect();
  const inView = el => { const r = el.getBoundingClientRect();
    return r.right > box.left && r.left < box.right && r.bottom > box.top && r.top < box.bottom; };
  const markers = [...document.querySelectorAll('.leaflet-marker-pane > *')];
  const shapes = [...document.querySelectorAll('path.leaflet-interactive')];
  const tiles = [...document.querySelectorAll('img.leaflet-tile')].filter(t => t.complete && t.naturalWidth);
  const base = [...document.querySelectorAll('.leaflet-control-layers-base label')]
    .filter(l => l.querySelector('input').checked).map(l => l.textContent.trim());
  return [markers.length, markers.filter(inView).length, shapes.length, shapes.filter(inView).length,
          tiles.length, base[0] || ''];
}"""

def inspect(browser, html, offline=False):
    page = browser.new_page(viewport={"width": 1000, "height": 700})
    page.context.set_offline(offline)
    errors = []
    page.on("pageerror", lambda err: errors.append(str(err)))
    page.goto(Path(html).resolve().as_uri(), wait_until="networkidle")
    counts = page.evaluate(COUNT)
    page.close()
    return counts, errors

files = ["denver-map.html", "denver-station-map.html", "station-markers.html", "station-types.html",
         "county-boundaries.html", "county-geopandas.html", "skip-reproject-gdf.html",
         "skip-reproject-file.html", "skip-reproject-center.html", "layer-control-default.html",
         "layer-control-map.html"]
print(f"{'file':27} {'markers':>7} {'in view':>7} {'shapes':>6} {'in view':>7} {'tiles':>5}  basemap shown")
with sync_playwright() as p:
    browser = p.chromium.launch()
    for html in files:
        (markers, markers_seen, shapes, shapes_seen, tiles, base), errors = inspect(browser, html)
        print(f"{html:27} {markers:>7} {markers_seen:>7} {shapes:>6} {shapes_seen:>7} {tiles:>5}  {base}"
              + (f"  errors: {errors}" if errors else ""))
    browser.close()
file                        markers in view shapes in view tiles  basemap shown
denver-map.html                   0       0      0       0    20  
denver-station-map.html           1       1      0       0    20  
station-markers.html              3       3      0       0    20  
station-types.html               20      20      0       0    20  
county-boundaries.html            0       0     64      64    20  openstreetmap
county-geopandas.html             0       0     64      64    20  
skip-reproject-gdf.html           0       0     64      64    20  
skip-reproject-file.html          0       0     64       0    20  
skip-reproject-center.html        0       0     64       0    10  
layer-control-default.html       20      20      0       0    60  Dark basemap
layer-control-map.html           20      20      0       0    20  openstreetmap

The three skip-reproject rows are the ones to read: all three hold 64 county shapes, and only the first shows any of them. Nothing in Python or in the browser console reports a problem for the other two.

Colorado's 64 counties: how many Leaflet drew inside the visible map, per input. county shapes inside the visible mapfile in degrees64GDF, reprojected64GDF left in UTM64 β€” Folium reprojectsfile in UTM0 β€” no errorcentre in UTM0 β€” no errorone null geometry63 β€” no error03264Every map saved without an error; only the browser shows the difference spatialworkflow.io
Measured in headless Chromium on this page. None of the failures raised an error in Python or in the browser.

Coordinates in the wrong order

Folium takes [lat, lon]. GeoJSON, shapely and points_from_xy use (lon, lat), so the swap is easy to make β€” and Folium checks for NaN but not for range:

import math

for location in ([39.7530, -104.9999], [-104.9999, 39.7530], [math.nan, -104.9999]):
    try:
        folium.Marker(location).add_to(folium.Map())
        print(location, "accepted")
    except ValueError as exc:
        print(location, "ValueError:", exc)

m = folium.Map(location=[39.7530, -104.9999], zoom_start=6)
for _, row in df.iterrows():
    folium.CircleMarker([row["lon"], row["lat"]], radius=4).add_to(m)    # swapped
m.save("swapped.html")
with sync_playwright() as p:
    browser = p.chromium.launch()
    (_, _, shapes, shapes_seen, _, _), errors = inspect(browser, "swapped.html")
    browser.close()
print(f"swapped: {shapes} circles drawn, {shapes_seen} in view, errors: {errors}")
[39.753, -104.9999] accepted
[-104.9999, 39.753] accepted
[nan, -104.9999] ValueError: Location values cannot contain NaNs.
swapped: 20 circles drawn, 0 in view, errors: []

A latitude of βˆ’105 does not exist, and Folium takes it anyway. The only way to catch it in Python is to check the ranges yourself before building the map: df["lat"].between(-90, 90).all().

Large layers: what the HTML weighs

Folium embeds the whole GeoJSON in the HTML file. The national county file shows what that costs, and what simplifying first saves:

counties = us[["GEOID", "NAME", "geometry"]].to_crs(4326)
for label, layer in [("full detail", counties),
                     ("simplify 0.01Β°", counties.assign(geometry=counties.simplify(0.01))),
                     ("simplify 0.05Β°", counties.assign(geometry=counties.simplify(0.05)))]:
    m = folium.Map(location=[39, -98], zoom_start=4)
    folium.GeoJson(layer, tooltip=folium.GeoJsonTooltip(fields=["NAME"])).add_to(m)
    m.save("us-counties.html")
    size = Path("us-counties.html").stat().st_size / 1e6
    with sync_playwright() as p:
        browser = p.chromium.launch()
        (_, _, shapes, _, _, _), errors = inspect(browser, "us-counties.html")
        browser.close()
    vertices = layer.geometry.count_coordinates().sum()
    print(f"{label:15} {vertices:>9,} vertices  {size:6.1f} MB  {shapes:,} shapes drawn")
full detail     1,034,082 vertices    27.2 MB  3,235 shapes drawn
simplify 0.01Β°     82,293 vertices     2.8 MB  3,235 shapes drawn
simplify 0.05Β°     32,604 vertices     1.5 MB  3,235 shapes drawn

What a "self-contained" HTML file still fetches

import re
from urllib.parse import urlparse

html = Path("layer-control-map.html").read_text()
assets = re.findall(r'<(?:script|link)[^>]+(?:src|href)="(https?://[^"]+)"', html)
print(len(assets), "scripts and stylesheets from", sorted({urlparse(u).netloc for u in assets}))
tiles = sorted(set(re.findall(r'"(https://[^"]*\{z\}[^"]*)"', html)))
print(len(tiles), "tile URL templates:")
for template in tiles:
    print("  " + template)

with sync_playwright() as p:
    browser = p.chromium.launch()
    for offline in (False, True):
        counts, errors = inspect(browser, "layer-control-map.html", offline=offline)
        print("offline" if offline else "online ", f"markers {counts[0]}, tiles {counts[4]},",
              "errors:", [e.splitlines()[0] for e in errors])
    browser.close()
10 scripts and stylesheets from ['cdn.jsdelivr.net', 'cdnjs.cloudflare.com', 'code.jquery.com', 'netdna.bootstrapcdn.com']
3 tile URL templates:
  https://tile.openstreetmap.org/{z}/{x}/{y}.png
  https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png
  https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png
online  markers 20, tiles 20, errors: []
offline markers 0, tiles 0, errors: ['L is not defined']

The map file carries the data, but not Leaflet, not its plugins and not a single tile. Opened with the network off, the page throws L is not defined on its first line of map code and draws nothing β€” not the 20 markers whose coordinates are sitting in the file. Test the output where it will be used.

Null and invalid geometries

import shapely

broken = gpd.read_file("data/colorado_counties.geojson")
broken.loc[0, "geometry"] = None                                   # a null geometry
broken.loc[1, "geometry"] = shapely.Polygon([(-105, 39), (-104, 40), (-104, 39), (-105, 40)])  # a bow-tie
print("valid:", broken.is_valid.sum(), "of", len(broken), "| null:", broken.geometry.isna().sum())

m = folium.Map(location=[39.0, -105.5], zoom_start=7)
folium.GeoJson(broken).add_to(m)
m.save("broken-geometries.html")
with sync_playwright() as p:
    browser = p.chromium.launch()
    (_, _, shapes, _, _, _), errors = inspect(browser, "broken-geometries.html")
    browser.close()
print(f"{shapes} of {len(broken)} shapes drawn, errors: {errors}")
valid: 62 of 64 | null: 1
63 of 64 shapes drawn, errors: []

Neither stops the layer. The null row is silently left out and the self-intersecting polygon is drawn as it is, so a map can look complete while missing a feature. Count nulls and check is_valid in GeoPandas before mapping; the browser will not tell you.

GeoPandas' own shortcut

GeoDataFrame.explore() builds the same Folium map in one call, and it copes with the station layer's datetime column that folium.GeoJson refused:

m = stations.explore(column="StnType", tooltip=["Code", "StationName", "DateModif"],
                     marker_kwds={"radius": 6})
m.save("stations-explore.html")
with sync_playwright() as p:
    browser = p.chromium.launch()
    (_, _, shapes, shapes_seen, tiles, _), errors = inspect(browser, "stations-explore.html")
    browser.close()
print(f"{type(m).__name__}: {shapes} station circles, {shapes_seen} in view, {tiles} tiles, errors: {errors}")
Map: 20 station circles, 20 in view, 20 tiles, errors: []

It returns an ordinary folium.Map, so everything above β€” extra layers, LayerControl, save() β€” still applies to the result.

Folium writes Leaflet for you

Folium is a Python interface to Leaflet, a JavaScript library for web maps. Folium handles the HTML and JavaScript generation, so you can build interactive maps from Python without writing frontend code.

When an interactive Folium map beats a static Matplotlib one. interactive or static?Static (Matplotlib)βœ“goes into reports and PDFsβœ“full control of stylingβœ“handles very large datasetsβœ“no browser neededInteractive (Folium)βœ“pan, zoom and clickβœ“popups carry attributesβœ“basemap gives contextβœ“shareable as one HTML fileFolium renders in the browser, so thousands of features will make it crawl spatialworkflow.io
Simplify or cluster before handing a large layer to a browser.

In practice, Folium is mainly a display tool. It is best used after you have already done your GIS processing. A common workflow is:

  1. read and clean data in GeoPandas
  2. fix or check the CRS
  3. select the attributes you want to show
  4. pass the result to Folium for visualization
  5. export the map to HTML

The most important GIS detail is coordinate reference systems. Web maps use latitude and longitude, so your vector data should usually be in EPSG:4326 before adding it to Folium. A GeoDataFrame with a CRS is the one case Folium fixes for you; a projected GeoJSON file, or a centre computed from projected bounds, lands in the wrong place with no error in Python or in the browser.

Folium is a good fit when you need:

  • a quick interactive map from Python
  • a deliverable that opens in a browser
  • markers, popups, tooltips, and layer controls
  • a simple way to share GIS outputs with non-GIS users

It is not the right tool for heavy spatial analysis, editing, or detailed cartographic control. Use GeoPandas for data preparation and Folium for the final interactive display.

Coordinate order, datetime columns and web resources

  • CRS issues: folium.GeoJson reprojects a GeoDataFrame that has a CRS, but not a GeoJSON file, and not the numbers you compute a centre from. Reproject with to_crs(epsg=4326) yourself so everything downstream is in degrees; a GeoDataFrame with no CRS at all raises Cannot transform naive geometries.
  • Latitude and longitude order: Folium uses [lat, lon], not [lon, lat], and accepts a latitude of βˆ’105 without complaint. Reversed coordinates are a common cause of misplaced markers.
  • Invalid geometries: A null geometry is dropped silently and a self-intersecting polygon is drawn as-is, so neither stops a layer rendering β€” both just make it wrong. Check with gdf.geometry.isna() and gdf.is_valid and repair before mapping.
  • Large datasets: The GeoJSON is embedded in the HTML: all 3,235 US counties at 1:500,000 made a 27.2 MB file, and simplifying to 0.01Β° brought it to 2.8 MB. Simplify geometries or reduce feature count before export.
  • Shapefiles are not added directly: Read shapefiles with GeoPandas first, then pass the GeoDataFrame to folium.GeoJson().
  • Datetime columns: folium.GeoJson(gdf) raises Object of type Timestamp is not JSON serializable on any datetime column. Drop it, convert it with .astype(str), or use gdf.explore(), which converts it for you.
  • Shared HTML files still load web resources: Leaflet, jQuery, Bootstrap and the marker icons come from four CDNs and every tile from its tile server. Offline, the saved map draws nothing at all. Test the output in the environment where it will be used.

FAQ

Can Folium display shapefiles directly?

No. Folium does not directly read shapefiles. Use GeoPandas to read the shapefile, reproject it to EPSG:4326, and then pass the GeoDataFrame into folium.GeoJson().

What CRS should I use with Folium?

Use WGS84 latitude and longitude, typically EPSG:4326, when preparing vector data for Folium. CRS problems are one of the most common reasons data appears misplaced.

How do I add popups from my data attributes?

For markers, build the popup string inside a loop:

popup=f"{row['name']} - {row['status']}"

For GeoJSON layers, use folium.GeoJsonTooltip or folium.GeoJsonPopup with field names from your data.

Why is my Folium map blank or misplaced?

Common causes include:

  • wrong CRS
  • latitude and longitude reversed
  • invalid or empty geometries
  • data outside the current map extent
  • a very large GeoJSON file causing rendering problems

Can I use GeoPandas data directly in Folium?

Yes. Pass it to folium.GeoJson(gdf) and then add tooltips, styling, and layer controls. Folium reprojects a GeoDataFrame with a CRS to EPSG:4326 itself, but reprojecting first keeps your bounds and centre in degrees, and datetime columns must be dropped or converted. gdf.explore() does all of it in one call.

How do I cluster many markers so the map stays readable?

Use the MarkerCluster plugin and add markers to it instead of directly to the map:

from folium.plugins import MarkerCluster

m = folium.Map(location=[39.0, -105.5], zoom_start=7)
cluster = MarkerCluster().add_to(m)
for lat, lon in zip(df["lat"], df["lon"]):
    folium.Marker([lat, lon]).add_to(cluster)
m.save("station-cluster.html")

Clustering groups nearby points at low zoom and expands them as the user zooms in, which keeps large point datasets responsive.

How do I make a choropleth map from a value column in Folium?

Pass a numeric field to folium.Choropleth, linking your GeoJSON geometry to the data by a shared key column. It bins the values into color classes and adds a legend automatically; for full control over styling you can instead use folium.GeoJson with a style_function that reads each feature's value.

How do I automatically fit the map to my data instead of guessing zoom_start?

Compute the bounds and call m.fit_bounds() with the corners in [[miny, minx], [maxy, maxx]] order (latitude, longitude):

minx, miny, maxx, maxy = gdf.total_bounds
m.fit_bounds([[miny, minx], [maxy, maxx]])

This frames the layer correctly without manually tuning the zoom level.

Can I add a circle sized by a data value instead of a pin marker?

Yes. Use folium.CircleMarker and set radius from your data, which is useful for showing magnitude such as population or measurement counts. Unlike folium.Circle, the radius is in screen pixels rather than meters, so it stays a constant size as you zoom.

Why is my Folium HTML file blank when I open it offline?

By default Folium loads the Leaflet library and basemap tiles from the internet, so an offline machine shows a blank page β€” the map code fails with L is not defined before drawing any of the data embedded in the file. Test the output where it will actually be used, and if you need a fully offline map, embed the required resources or use a tile source available on that network.