My Folium Map Is Blank or Will Not Display: How to Fix It

Problem statement

Folium is the library where the code runs, the object exists, the file is written β€” and nothing appears.

import folium

m = folium.Map(location=[53.48, -2.24], zoom_start=12)
folium.GeoJson(gdf).add_to(m)
m

In a notebook: an empty grey rectangle, or nothing at all where the map should be. Saved to HTML: a page with the OpenStreetMap tiles but no data. Or a fully white page. Or the map appears but zoomed out to the whole world with your data invisible somewhere.

No exception is raised in any of these cases. Folium builds an HTML document with embedded JavaScript, hands it to a browser, and whatever goes wrong goes wrong at a layer Python never sees.

There are eight causes, and the useful thing is that they split cleanly: some are Python-side and visible from Python, and some are browser-side and only visible in the browser console.

Quick answer

import folium, geopandas as gpd

gdf = gpd.read_file("wards.gpkg").to_crs(4326)      # ← Folium needs WGS 84

m = folium.Map(location=[gdf.geometry.centroid.y.mean(),
                         gdf.geometry.centroid.x.mean()],
               zoom_start=11, tiles="CartoDB positron")
folium.GeoJson(gdf.to_json()).add_to(m)
m.fit_bounds([[gdf.total_bounds[1], gdf.total_bounds[0]],
              [gdf.total_bounds[3], gdf.total_bounds[2]]])
m.save("map.html")
Triage rows matching each blank-Folium symptom to its cause and fix.
Half of these are visible from Python. The other half need the browser console.
Symptom Cause Fix
tiles show, data does not data not in EPSG:4326 gdf.to_crs(4326)
map is over the wrong place location is [lon, lat] Folium takes [lat, lon]
whole world, data invisible no bounds set m.fit_bounds(...)
nothing in the notebook notebook cannot render HTML m.save() and open the file
grey box, no tiles no internet, or a blocked CDN check the browser console
blank at ~50 MB+ GeoJSON too large for the browser simplify, or use a tile layer
TypeError: … not JSON serializable datetime or NaN in the attributes coerce to strings first
empty white page data has invalid or null geometry validate before adding

The single most common: Folium is [latitude, longitude], the opposite order from GeoJSON and Shapely.

Step-by-step solution

1. Reproject to EPSG:4326

Leaflet β€” the JavaScript library Folium wraps β€” expects geographic coordinates in WGS 84. It projects them to Web Mercator itself for display. Hand it British National Grid metres and it treats 351204 as a longitude:

print(gdf.crs)                            # EPSG:27700
print(gdf.total_bounds)                   # [351204. 381009. 407881. 445902.]

gdf = gdf.to_crs(4326)
print(gdf.total_bounds)                   # [-2.7013 53.3382 -1.9106 53.9004]

Longitudes must be within Β±180 and latitudes within Β±90. Anything outside that is off the map, and Leaflet draws it nowhere without complaint.

minx, miny, maxx, maxy = gdf.total_bounds
if not (-180 <= minx <= 180 and -90 <= miny <= 90):
    raise ValueError(f"bounds are not lat/lon degrees: {gdf.total_bounds}")

Note this is the opposite of the requirement for a contextily basemap, which needs EPSG:3857. Static matplotlib maps draw projected coordinates directly; Leaflet projects for you and therefore wants unprojected input.

2. Get the coordinate order right

# ❌ this puts you in the Indian Ocean
m = folium.Map(location=[-2.24, 53.48])

# βœ… Folium takes [latitude, longitude]
m = folium.Map(location=[53.48, -2.24])

Folium's location, fit_bounds and marker positions are all [lat, lon]. GeoJSON, Shapely and total_bounds are all [x, y] β€” that is, [lon, lat]. Converting between them is where the mistake happens:

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

A useful sanity check: British latitudes are around 50–59 and longitudes around βˆ’8 to 2. A "latitude" of βˆ’2.24 is not a British latitude.

3. Set the view from the data

minx, miny, maxx, maxy = gdf.total_bounds
m = folium.Map(location=[(miny + maxy) / 2, (minx + maxx) / 2],
               zoom_start=11, tiles="CartoDB positron")
folium.GeoJson(gdf.to_json()).add_to(m)
m.fit_bounds([[miny, minx], [maxy, maxx]])       # after adding the layers

fit_bounds after adding the data is more reliable than guessing zoom_start, and it makes the map correct for any extent.

Use representative_point() rather than centroid when centring on a single feature β€” the centroid of a concave shape can fall outside it, and for a multipart layer it can land between the parts.

4. Fix the JSON serialisation errors

Folium converts your data to GeoJSON, which supports only strings, numbers, booleans and null. Anything else raises:

TypeError: Object of type Timestamp is not JSON serializable
import numpy as np, pandas as pd

def json_safe(gdf):
    out = gdf.copy()
    for col in out.columns:
        if col == out.geometry.name:
            continue
        s = out[col]
        if pd.api.types.is_datetime64_any_dtype(s):
            out[col] = s.dt.strftime("%Y-%m-%d")
        elif pd.api.types.is_numeric_dtype(s):
            out[col] = s.replace([np.inf, -np.inf], np.nan).where(s.notna(), None)
        elif not pd.api.types.is_bool_dtype(s):
            out[col] = s.astype(object).where(s.notna(), None)
    return out

NaN is the subtle one: it is a float, so it does not raise, but NaN is not valid JSON. Folium writes it into the document, the browser's JSON parser rejects it, and the whole map fails to render β€” a completely blank page from one missing value. Converting to None produces null, which is valid.

5. Deal with size

Bars showing GeoJSON payload size against browser behaviour from responsive to unusable.
The GeoJSON is embedded in the HTML file and parsed by the browser. There is a ceiling.

folium.GeoJson embeds the entire dataset in the HTML file as text. The browser parses it and Leaflet creates an SVG element per feature:

size_mb = len(gdf.to_json()) / 1e6
print(f"{len(gdf):,} features, {size_mb:.1f} MB of GeoJSON, "
      f"{gdf.geometry.apply(lambda g: 0 if g is None else len(g.wkb)).sum()/1e6:.1f} MB of geometry")
8,436 features, 61.4 MB of GeoJSON, 24.1 MB of geometry

Sixty megabytes is well past what a browser handles comfortably. Roughly: under 5 MB is fine, 5–20 MB is slow to load, and beyond about 25 MB many browsers hang or run out of memory.

Three fixes, in order:

# (a) simplify β€” usually enough, and by far the biggest win
gdf["geometry"] = gdf.geometry.simplify(0.0002, preserve_topology=True)
print(f"{len(gdf.to_json())/1e6:.1f} MB after simplifying")     # 4.8 MB

# (b) drop the columns you are not showing
gdf = gdf[["ward_name", "income", "geometry"]]

# (c) many points β†’ a cluster or a heatmap instead of individual markers
from folium.plugins import MarkerCluster, HeatMap
MarkerCluster([[p.y, p.x] for p in points.geometry]).add_to(m)

Simplify tolerance is in the CRS's units, so in EPSG:4326 it is degrees β€” 0.0002Β° is roughly 20 m. Simplify in a projected CRS if you want to reason in metres, then reproject:

gdf = gdf.to_crs(27700)
gdf["geometry"] = gdf.geometry.simplify(20, preserve_topology=True)
gdf = gdf.to_crs(4326)

6. Check geometry validity

Null and empty geometries produce a GeoJSON feature with "geometry": null, which Leaflet skips silently. Invalid geometries can break rendering for the whole layer:

print(f"null      {gdf.geometry.isna().sum()}")
print(f"empty     {gdf.geometry.is_empty.sum()}")
print(f"invalid   {(~gdf.geometry.is_valid).sum()}")

gdf = gdf[gdf.geometry.notna() & ~gdf.geometry.is_empty].copy()
gdf["geometry"] = gdf.geometry.make_valid()

See how to fix invalid geometries and how to remove null and empty geometries.

7. When the notebook shows nothing, save to a file

Notebook rendering is its own layer of things that can go wrong: a JupyterLab extension mismatch, an output size limit, a sandboxed iframe, VS Code's notebook renderer. Take it out of the equation:

m.save("map.html")
import webbrowser, pathlib
webbrowser.open(pathlib.Path("map.html").resolve().as_uri())

If the saved file works and the notebook does not, the problem is the notebook and none of the above applies. Notebooks also truncate large outputs, so a 60 MB map may be silently cut off in a cell and fine in a file.

8. Read the browser console

For anything that survives the Python-side checks, the answer is in the browser. Open the developer tools (F12) and look at Console and Network:

Console message Meaning
Unexpected token N in JSON NaN in the data β€” step 4
Failed to load resource: cdn.jsdelivr.net offline, or a blocked CDN
Refused to load … Content Security Policy a CSP is blocking Leaflet or the tiles
Uncaught TypeError: Cannot read … of undefined malformed GeoJSON structure
nothing at all, grey box tiles blocked or unreachable

Folium loads Leaflet and its plugins from CDNs, so an offline machine or a locked-down corporate network produces a page with no map and no Python-side symptom whatsoever.

Code examples

Example 1: a pre-flight check that catches the Python-side causes

import json
import numpy as np
import pandas as pd
import geopandas as gpd

def check_folium_ready(gdf, *, max_mb=20):
    problems, notes = [], []

    if gdf.crs is None:
        problems.append("no CRS β€” Folium needs EPSG:4326")
    elif gdf.crs.to_epsg() != 4326:
        problems.append(f"CRS is {gdf.crs.to_epsg()} β€” call gdf.to_crs(4326)")
    else:
        minx, miny, maxx, maxy = gdf.total_bounds
        if not (-180 <= minx <= 180 and -180 <= maxx <= 180
                and -90 <= miny <= 90 and -90 <= maxy <= 90):
            problems.append(f"bounds outside lat/lon range: {gdf.total_bounds}")
        if abs(miny) > 85:
            notes.append("data beyond Β±85Β° β€” Web Mercator cannot show it")

    n_null = gdf.geometry.isna().sum()
    n_empty = gdf.geometry.is_empty.sum()
    n_invalid = (~gdf.geometry.is_valid).sum()
    if n_null or n_empty:
        problems.append(f"{n_null} null and {n_empty} empty geometries β€” filter them")
    if n_invalid:
        problems.append(f"{n_invalid} invalid geometries β€” call make_valid()")

    for col in gdf.columns:
        if col == gdf.geometry.name:
            continue
        s = gdf[col]
        if pd.api.types.is_datetime64_any_dtype(s):
            problems.append(f"column '{col}' is datetime β€” not JSON serialisable")
        elif pd.api.types.is_numeric_dtype(s):
            if s.isna().any():
                problems.append(f"column '{col}' has {s.isna().sum():,} NaN β€” "
                                f"NaN is invalid JSON and blanks the whole map")
            if np.isinf(s.replace({pd.NA: np.nan}).astype("float64", errors="ignore")).any():
                problems.append(f"column '{col}' has infinities β€” invalid JSON")

    try:
        payload = gdf.to_json()
        mb = len(payload) / 1e6
        json.loads(payload)                       # proves it is valid JSON
        if mb > max_mb:
            problems.append(f"{mb:.1f} MB of GeoJSON β€” simplify or cluster "
                            f"(browsers struggle beyond ~{max_mb} MB)")
        else:
            notes.append(f"{mb:.1f} MB of GeoJSON, {len(gdf):,} features")
    except (TypeError, ValueError) as exc:
        problems.append(f"to_json failed: {type(exc).__name__}: {exc}")

    for p in problems:
        print(f"  βœ— {p}")
    for n in notes:
        print(f"  Β· {n}")
    return not problems

check_folium_ready(gpd.read_file("wards.gpkg"))
  βœ— CRS is 27700 β€” call gdf.to_crs(4326)
  βœ— 3 null and 1 empty geometries β€” filter them
  βœ— column 'survey_date' is datetime β€” not JSON serialisable
  βœ— column 'income' has 412 NaN β€” NaN is invalid JSON and blanks the whole map
  Β· 61.4 MB of GeoJSON, 8,436 features

json.loads(gdf.to_json()) is the check worth stealing. It is the same parse the browser performs, so anything it rejects will blank the map β€” and it fails here in Python, where you can see the error, rather than silently in a browser.

Example 2: a function that prepares data correctly

import json
import numpy as np
import pandas as pd
import geopandas as gpd
import folium

def to_folium(gdf, *, columns=None, simplify_m=None, max_mb=20):
    """Return a GeoDataFrame that Folium can render, or raise with the reason."""
    gdf = gdf.copy()

    if gdf.crs is None:
        raise ValueError("no CRS β€” identify it before mapping")

    if simplify_m:
        work_crs = gdf.crs if gdf.crs.is_projected else gdf.estimate_utm_crs()
        gdf = gdf.to_crs(work_crs)
        gdf["geometry"] = gdf.geometry.simplify(simplify_m, preserve_topology=True)

    gdf = gdf.to_crs(4326)

    gdf = gdf[gdf.geometry.notna() & ~gdf.geometry.is_empty].copy()
    invalid = ~gdf.geometry.is_valid
    if invalid.any():
        gdf.loc[invalid, "geometry"] = gdf.loc[invalid, "geometry"].make_valid()

    if columns:
        gdf = gdf[[*columns, gdf.geometry.name]]

    for col in gdf.columns:
        if col == gdf.geometry.name:
            continue
        s = gdf[col]
        if pd.api.types.is_datetime64_any_dtype(s):
            gdf[col] = s.dt.strftime("%Y-%m-%d")
        elif pd.api.types.is_numeric_dtype(s):
            gdf[col] = s.replace([np.inf, -np.inf], np.nan).astype(object).where(s.notna(), None)
        elif not pd.api.types.is_bool_dtype(s):
            gdf[col] = s.astype(object).where(s.notna(), None)

    payload = gdf.to_json()
    json.loads(payload)                            # fail here, not in the browser
    mb = len(payload) / 1e6
    if mb > max_mb:
        raise ValueError(f"{mb:.1f} MB of GeoJSON exceeds {max_mb} MB β€” "
                         f"pass simplify_m=, fewer columns, or use clustering")
    print(f"  ready: {len(gdf):,} features, {mb:.1f} MB")
    return gdf

def quick_map(gdf, *, column=None, tooltip=None, tiles="CartoDB positron", **kw):
    gdf = to_folium(gdf, **kw)
    minx, miny, maxx, maxy = gdf.total_bounds
    m = folium.Map(location=[(miny + maxy) / 2, (minx + maxx) / 2], tiles=tiles)

    style = (lambda _: {"fillColor": "#ef4444", "color": "#991b1b",
                        "weight": 1, "fillOpacity": 0.4})
    layer = folium.GeoJson(
        gdf.to_json(), style_function=style,
        tooltip=folium.GeoJsonTooltip(fields=tooltip) if tooltip else None,
    )
    layer.add_to(m)
    m.fit_bounds([[miny, minx], [maxy, maxx]])     # [lat, lon] pairs
    return m

m = quick_map(gpd.read_file("wards.gpkg"),
              columns=["ward_name", "income"],
              tooltip=["ward_name", "income"],
              simplify_m=25)
m.save("wards.html")
  ready: 8,432 features, 4.6 MB

Simplifying in a projected CRS and then reprojecting is the detail worth copying. simplify(0.0002) in EPSG:4326 is a tolerance in degrees, which means a different ground distance at every latitude; estimate_utm_crs() picks a metric CRS for the data's location so 25 reliably means 25 metres.

json.loads before returning turns every serialisation problem into a Python exception with a line number, instead of a blank browser page.

Example 3: large point datasets without killing the browser

import folium
from folium.plugins import MarkerCluster, HeatMap, FastMarkerCluster
import geopandas as gpd

points = gpd.read_file("incidents.gpkg").to_crs(4326)
print(f"{len(points):,} points")                   # 184,204 points

minx, miny, maxx, maxy = points.total_bounds
m = folium.Map(location=[(miny + maxy) / 2, (minx + maxx) / 2],
               zoom_start=10, tiles="CartoDB positron")

coords = [[p.y, p.x] for p in points.geometry if p is not None and not p.is_empty]

# clustering: individual points survive, drawn only where the zoom warrants it
FastMarkerCluster(coords, name="Incidents (clustered)").add_to(m)

# a heatmap: density, and far cheaper for the browser
HeatMap(coords, name="Density", radius=12, blur=18,
        min_opacity=0.25, show=False).add_to(m)

folium.LayerControl(collapsed=False).add_to(m)
m.fit_bounds([[miny, minx], [maxy, maxx]])
m.save("incidents.html")

184,204 individual markers would create 184,204 DOM elements and freeze any browser. FastMarkerCluster sends the raw coordinates and does the clustering in JavaScript, so the page stays responsive and individual points reappear as you zoom in. HeatMap is cheaper still but shows density rather than features, which is a different map.

show=False on the second layer means it starts hidden, and LayerControl lets the reader switch β€” two overlapping density representations at once is unreadable.

Note that neither plugin carries attributes, so a tooltip needs the full GeoJson layer. When both are required, filter to a manageable subset for the interactive layer and use clustering for the rest.

Explanation

Flow from GeoDataFrame through Folium to an HTML file, then to the browser where Python can no longer see failures.
Python's job ends when the file is written. Everything after that fails silently.

Folium is a code generator, and every symptom in this article follows from that.

It does not draw anything. It builds an HTML document containing your data as embedded GeoJSON, plus JavaScript that constructs a Leaflet map from it, plus <script> tags pointing at CDN-hosted libraries. Python's job ends when the file is written. Everything after β€” parsing the JSON, fetching Leaflet, requesting tiles, creating DOM elements β€” happens in a browser, where Python cannot see it and no exception can reach you.

This explains the characteristic frustration: the code succeeds and the map is blank. From Python's point of view, writing an HTML file containing invalid JSON is a complete success.

The CRS requirement comes from Leaflet's own model. Leaflet works in geographic coordinates and projects to Web Mercator internally for tile alignment. Feeding it projected coordinates means feeding numbers like 351204 into a field it interprets as longitude, which is outside the valid range and simply renders nowhere. Note the contrast with a contextily basemap, which needs EPSG:3857 precisely because matplotlib does not project for you β€” the two libraries want opposite things for the same reason, and it is a reliable source of confusion.

The coordinate order is a genuine inconsistency in the ecosystem, not a Folium quirk. GeoJSON specifies [longitude, latitude]; Leaflet's API uses [latitude, longitude]. Folium follows Leaflet for its own arguments and GeoJSON for its data, so both conventions are live in the same script. Nothing can detect the mistake, because both orders are numerically valid β€” swapping them just puts you somewhere else on Earth, which is exactly the failure described in my points plot in the ocean.

NaN deserves its own note because of how disproportionate its effect is. JSON has no NaN literal. Python's json module writes a bare NaN token by default, which is valid Python and invalid JSON. A browser's JSON.parse throws on the first one, the script aborts, and the entire map fails β€” from one missing value in one attribute of one feature. A blank page caused by a single null is not an obvious hypothesis, which is why json.loads(gdf.to_json()) is worth running as a matter of routine.

Size is a hard architectural limit, not a tuning problem. The GeoJSON lives inside the HTML file, so a 60 MB dataset is a 60 MB page that the browser must download, parse into JavaScript objects, and turn into one SVG element per feature. Browsers are not built for that, and no Folium option changes it. Simplification helps because it attacks the vertex count directly; clustering helps because it defers element creation to the JavaScript layer; and beyond a few tens of megabytes the honest answer is a different architecture β€” vector tiles, a tile server, or a static image.

Finally, the CDN dependency is worth knowing before it bites. Folium's output references Leaflet and its plugins over the network. On an air-gapped machine, behind a restrictive proxy, or under a strict Content Security Policy, the page loads and no map appears β€” with nothing wrong in Python, nothing wrong in the file, and the explanation available only in the browser's console.

Edge cases or notes

  • Folium takes [lat, lon]; GeoJSON and total_bounds are [lon, lat]. Convert deliberately.
  • NaN is not valid JSON. One NaN blanks the whole map. Convert to None.
  • json.loads(gdf.to_json()) performs the same parse the browser will, and fails where you can see it.
  • Simplify tolerance is in CRS units. In EPSG:4326 that is degrees; simplify in a projected CRS to reason in metres.
  • folium.GeoJson accepts a GeoDataFrame or a JSON string. The string form makes the payload size visible.
  • m.save() then open the file to rule out notebook rendering entirely.
  • Folium loads Leaflet from CDNs. Offline or CSP-restricted environments show a blank page with no Python-side symptom.
  • style_function must return a dict of Leaflet path options (fillColor, color, weight, fillOpacity), not matplotlib names.
  • GeoJsonTooltip fields must exist in the data, or the tooltip silently shows nothing.
  • Beyond Β±85Β° latitude Web Mercator is undefined, so polar data cannot be shown.

FAQ

Why do the tiles appear but not my data?

The data is not in EPSG:4326. Leaflet reads the coordinates as longitude and latitude, so projected metres fall outside the valid range and render nowhere. Call gdf.to_crs(4326).

Why is my map centred on the wrong place?

location takes [latitude, longitude], the opposite of GeoJSON's order. A "latitude" of βˆ’2.24 with a "longitude" of 53.48 puts you in the Indian Ocean.

Why is the whole page blank?

Most often a NaN in an attribute column: NaN is not valid JSON, so the browser's parser throws and nothing renders. Run json.loads(gdf.to_json()) to catch it in Python.

How large can a Folium map be?

Under about 5 MB of GeoJSON is comfortable, 5–20 MB is slow, and beyond roughly 25 MB many browsers hang. The data is embedded in the HTML file, so this is architectural.

Why does nothing appear in my notebook?

Notebook HTML rendering is its own layer. Call m.save("map.html") and open the file β€” if that works, the problem is the notebook and not the map.

How do I show 200,000 points?

FastMarkerCluster for individual features that reappear on zoom, or HeatMap for density. Individual markers create one DOM element each and will freeze the browser.

Does Folium work offline?

Not by default β€” it loads Leaflet from a CDN. The page renders with no map and no Python-side error. Check the browser console; self-hosting the assets is possible but not automatic.