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.
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_boundslies within(-180, -90, 180, 90). Values outside this range indicate remainingEPSG:3857meter values in the output. - Visual spot-check — open the saved
.htmlfile 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_locfromepsg3857_to_folium()and confirm the first value is a plausible latitude (roughly-90to90) and the second a plausible longitude (-180to180). - GeoJSON coordinate order — log the first feature’s
geometry.coordinatesarray 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.
Related
- CRS & Projection Management — parent guide covering the full server-side transformation and validation pipeline
- Tile vs Vector Rendering Strategies — how your CRS choice constrains which rendering approach fits your dashboard
- Base Layer Selection & Switching — matching tile providers to the EPSG:3857 rendering context Folium uses
- How to Choose Between Raster Tiles and Vector Tiles for Web Dashboards — decision guide for the rendering layer that sits above the projection pipeline