Implementing EPSG:3857 vs EPSG:4326 in Folium

Part of the CRS & Projection Management guide.

Operative rule: always supply coordinates to Folium in EPSG:4326 (WGS84 latitude/longitude) — never pre-projected meter values from EPSG:3857.

How the Projection Pipeline Works

Folium is a Python wrapper around Leaflet.js, which defaults its canvas to L.CRS.EPSG3857. The tile grid, zoom levels, and pixel math all operate in Web Mercator meters. Leaflet’s public API is deliberately designed around EPSG:4326 for ergonomics: every marker, polygon, and circle method accepts [lat, lon] degrees, and Leaflet projects those values internally before placing them on the Mercator canvas.

The consequence is a clean separation of responsibilities. Your Python code works in geographic degrees; Folium serialises them into Leaflet’s JavaScript; Leaflet handles the spherical Mercator projection internally before aligning features with the EPSG:3857 tiles served by providers like OpenStreetMap or CartoDB. There is no crs constructor argument on folium.Map() — the rendering CRS is managed entirely by Leaflet and cannot be overridden through Folium’s Python API. For projects where CRS & Projection Management spans multiple spatial data sources, normalising to EPSG:4326 before any Folium call is the single safest policy.

Folium Projection Pipeline Data flow diagram showing that Python code supplies EPSG:4326 coordinates to Folium, which serialises them for Leaflet. Leaflet projects internally to EPSG:3857 before rendering tiles and vector overlays on the canvas. Python Code lat/lon degrees (EPSG:4326) serialise Folium Python → JS bridge emits Leaflet API calls project Leaflet internals L.CRS.EPSG3857 spherical Mercator math render Map Canvas tiles + overlays (EPSG:3857) ⚠ Passing EPSG:3857 meter values at step 1 displaces features by thousands of km

Production-Ready Implementation

The snippet below covers the three scenarios encountered most in production: native EPSG:4326 input, legacy EPSG:3857 data that must be converted upstream, and GeoJSON overlays. The pyproj transformer is constructed once and reused to minimise overhead.

import folium
import pyproj

# -- CRS setup (construct once; reuse across the pipeline) -------------------
wgs84 = pyproj.CRS("EPSG:4326")
web_mercator = pyproj.CRS("EPSG:3857")

# always_xy=True forces (longitude, latitude) axis order regardless of the
# CRS definition's native axis convention — critical for EPSG:4326.
transformer_to_wgs84 = pyproj.Transformer.from_crs(
    web_mercator, wgs84, always_xy=True
)


def epsg3857_to_folium(x_meters: float, y_meters: float) -> list[float]:
    """Convert EPSG:3857 meter coordinates to a Folium-ready [lat, lon] pair."""
    lon, lat = transformer_to_wgs84.transform(x_meters, y_meters)
    return [lat, lon]  # Folium strictly requires [lat, lon] order


# -- Map initialisation -------------------------------------------------------
# Location and all subsequent coordinates must be EPSG:4326 degrees.
m = folium.Map(
    location=[40.7128, -74.0060],   # New York City — WGS84 latitude, longitude
    zoom_start=12,
    tiles="CartoDB positron",
)

# Scenario A: native EPSG:4326 — pass directly, no conversion required
folium.Marker(
    location=[40.7128, -74.0060],
    popup="Native WGS84 coordinate",
    tooltip="EPSG:4326 input",
).add_to(m)

# Scenario B: legacy EPSG:3857 data — convert upstream before passing to Folium
legacy_x = -8_238_310.24   # NYC easting in Web Mercator meters
legacy_y =  4_969_803.55   # NYC northing in Web Mercator meters
converted = epsg3857_to_folium(legacy_x, legacy_y)

folium.CircleMarker(
    location=converted,
    radius=8,
    color="steelblue",
    fill=True,
    popup="Converted from EPSG:3857",
).add_to(m)

# Scenario C: GeoJSON overlay — RFC 7946 mandates WGS84 with [lon, lat] order
geojson_feature = {
    "type": "Feature",
    "geometry": {
        "type": "Point",
        "coordinates": [-74.0060, 40.7128],  # GeoJSON spec: [longitude, latitude]
    },
    "properties": {"name": "GeoJSON point"},
}
folium.GeoJson(geojson_feature).add_to(m)

m.save("map_output.html")

Alternative Variants

Bulk reprojection with geopandas

When an entire GeoDataFrame arrives in EPSG:3857 — common with PostGIS exports or shapefiles — reproject the whole frame before iterating. This avoids calling pyproj.Transformer per row, which is significantly slower on large datasets.

import geopandas as gpd

# Load shapefile or PostGIS export in EPSG:3857
gdf = gpd.read_file("city_districts.shp")         # assumes EPSG:3857
gdf_wgs84 = gdf.to_crs("EPSG:4326")               # reproject entire frame

m = folium.Map(location=[40.71, -74.01], zoom_start=11)

# folium.GeoJson accepts GeoDataFrame directly; geometry must already be EPSG:4326
folium.GeoJson(
    gdf_wgs84,
    name="City districts",
    tooltip=folium.GeoJsonTooltip(fields=["district_name"]),
).add_to(m)

folium.LayerControl().add_to(m)
m.save("districts.html")

Configuration reference: coordinate contracts by Folium method

Folium method Expected axis order Expected CRS
folium.Map(location=…) [latitude, longitude] EPSG:4326
folium.Marker(location=…) [latitude, longitude] EPSG:4326
folium.CircleMarker(location=…) [latitude, longitude] EPSG:4326
folium.PolyLine(locations=…) [[lat, lon], …] EPSG:4326
folium.GeoJson(data=…) [longitude, latitude] per RFC 7946 EPSG:4326
folium.Choropleth(geo_data=…) [longitude, latitude] per RFC 7946 EPSG:4326

Note that GeoJson and Choropleth consume GeoJSON format, where the spec reverses the axis order to [longitude, latitude]. All other Folium methods use Leaflet’s [latitude, longitude] convention.

Verification Steps

After adding the conversion layer, confirm correct behaviour with these checks before deploying:

  • Bounding box sanity check — after transformation, assert that gdf_wgs84.total_bounds lies within (-180, -90, 180, 90). Values outside this range indicate remaining EPSG:3857 meter values in the output.
  • Visual spot-check — open the saved .html file in a browser, pan to the expected region, and verify markers land on the correct streets. A displaced marker by thousands of kilometres is the most obvious sign of a missed conversion.
  • Axis-order assertion — print converted_loc from epsg3857_to_folium() and confirm the first value is a plausible latitude (roughly -90 to 90) and the second a plausible longitude (-180 to 180).
  • GeoJSON coordinate order — log the first feature’s geometry.coordinates array and confirm the first element is longitude (negative for western hemisphere), not latitude.

Common Errors & Fixes

Marker renders in the South Atlantic or Antarctica

Meter-scale EPSG:3857 values (e.g. x = -8_238_310) are being interpreted as degrees. Folium does not raise an exception; it passes the values to Leaflet, which places the marker at roughly -8_238_310° longitude — far off any land mass. Fix: pipe all EPSG:3857 coordinates through epsg3857_to_folium() before any Folium call.

Marker is in the correct city but appears flipped north-south

[longitude, latitude] order was passed to a Folium method that expects [latitude, longitude]. The symptom is a marker that appears mirrored across the equator or displaced within the same country. Fix: swap the tuple. For pyproj, set always_xy=True on the Transformer and swap the return value to [lat, lon] as shown above.

GeoJson overlay is invisible or offset from tile background

The source GeoJSON uses a local projected CRS (e.g. a national grid or UTM zone) rather than EPSG:4326. RFC 7946 prohibits custom crs members, so many parsers silently assume WGS84 and render coordinates in the wrong location. Fix: reprojects the GeoDataFrame with gdf.to_crs("EPSG:4326") and re-export to GeoJSON before passing to folium.GeoJson().

pyproj produces unexpected results without always_xy=True

EPSG:4326 defines its axes as (latitude, longitude) in that order — the opposite of the (x, y) / (easting, northing) convention most developers assume. Without always_xy=True, pyproj.Transformer honours the CRS axis order, silently swapping output coordinates. The symptom is a correctly projected location that appears reflected across the diagonal. Fix: always construct the transformer with always_xy=True to force (longitude, latitude) output, then swap to [lat, lon] for Folium.