Reprojecting Between EPSG:4326 and EPSG:3857 Without Distortion
Reproject with to_crs(3857) only for display, clip geometries to ±85.06° latitude first, and never measure distance or area in Web Mercator — switch to an equal-area or UTM CRS for any calculation.
Jump to heading Why this matters
Almost every web tile map — Folium, Leaflet, Deck.gl, pydeck — renders in Web Mercator (EPSG:3857), while most data is stored and exchanged in WGS 84 (EPSG:4326). So the single most common reprojection in a spatial dashboard is the hop between these two, and it is also the one most often done wrong. Two mistakes dominate: measuring area or distance in 3857, where the numbers are inflated by a latitude-dependent factor, and reprojecting geometries that extend past the projection’s ±85.06° limit, where coordinates blow up to infinity. Doing this hop correctly is the display half of the CRS & coordinate systems reference; the measurement half is covered by looking up EPSG codes for your region.
Jump to heading Prerequisites
- Python 3.10+ with
geopandas>=0.14,shapely>=2.0, andpyproj>=3.5 - A
GeoDataFramewhose.crsis known — see the GeoDataFrame schema reference if it isNone - The reprojected layer will feed a live map, as described under dynamic spatial filtering
Jump to heading Step-by-step solution
Jump to heading Step 1 — Understand why measurement in 3857 is wrong
Web Mercator is conformal: it preserves the shape of small features by stretching the map vertically as you move away from the equator. The vertical scale factor is 1 / cos(latitude), which means a shape at 60° latitude covers roughly four times the area on the map that the same shape covers at the equator. The diagram makes the distortion concrete.
The takeaway: 3857 is a display CRS. Any number you need — a length, an area, a buffer radius — is computed in a different CRS.
Jump to heading Step 2 — Reproject the GeoDataFrame with to_crs
The transform itself is one call. Start from a frame you know is in 4326.
import geopandas as gpd
from shapely.geometry import Point
# Plausible points over Berlin and Hamburg (lon, lat)
gdf = gpd.GeoDataFrame(
{"city": ["Berlin", "Hamburg"]},
geometry=[Point(13.40, 52.52), Point(9.99, 53.55)],
crs="EPSG:4326",
)
gdf_web = gdf.to_crs("EPSG:3857")
print(gdf_web.geometry.iloc[0]) # POINT (1491772.6 6894111.9) — metres
Berlin’s (13.40, 52.52) becomes roughly (1,491,773, 6,894,112) in Web Mercator metres. Those large easting/northing values are correct — they are metres from the projection origin, not a bug.
Web Mercator’s y coordinate tends to infinity as latitude approaches ±90°, so the projection is cut off at ±85.06°. A dataset with an Arctic vertex — a global coastline, an ice-extent polygon — will produce infinite coordinates after to_crs. Clip to the valid band before reprojecting.
from shapely.geometry import box
MERC_LAT = 85.06
valid_band = box(-180, -MERC_LAT, 180, MERC_LAT)
gdf_clipped = gpd.clip(gdf, valid_band) # drop/trim beyond ±85.06°
gdf_web = gdf_clipped.to_crs("EPSG:3857")
# guard against any surviving infinities
assert gdf_web.geometry.is_valid.all()
assert gdf_web.total_bounds[3] < 2.0e7 # northing stays finite
When a filter or tooltip needs an actual area or distance, reproject a copy into the right measurement CRS — never read it from the 3857 frame.
# area: use an equal-area CRS
gdf["area_km2"] = gdf.to_crs("EPSG:6933").area / 1_000_000
# distance between the two cities: use a local UTM zone (metres)
metric = gdf.to_crs("EPSG:25832")
d_km = metric.geometry.iloc[0].distance(metric.geometry.iloc[1]) / 1000
print(f"{d_km:.0f} km") # ~255 km Berlin–Hamburg
Jump to heading Verification
Confirm the round-trip is lossless within the valid band — the fastest proof the transform is correct.
import geopandas as gpd
from shapely.geometry import Point
gdf = gpd.GeoDataFrame(
geometry=[Point(13.40, 52.52)], crs="EPSG:4326" # Berlin
)
roundtrip = gdf.to_crs("EPSG:3857").to_crs("EPSG:4326")
orig = gdf.geometry.iloc[0]
back = roundtrip.geometry.iloc[0]
assert abs(orig.x - back.x) < 1e-6, "longitude drifted on round-trip"
assert abs(orig.y - back.y) < 1e-6, "latitude drifted on round-trip"
print("round-trip clean")
Jump to heading Edge cases and gotchas
- Poles clipping: any geometry crossing ±85.06° must be clipped first (Step 3), or a single vertex sends the whole layer’s bounds to infinity and the map fails to fit.
- Axis order: if points arrive as
(lat, lon)instead of(lon, lat), they will land in the wrong hemisphere. ConstructPoint(lon, lat)and, when usingpyproj.Transformerdirectly, passalways_xy=True. - Area in 3857: calling
.areaon the reprojected frame returns an inflated value that grows with latitude. Always reproject toEPSG:6933,EPSG:3035, orEPSG:5070for area (Step 4).
Jump to heading Round-tripping is not free, but it is usually fine
Converting from the geographic system to Web Mercator and back does not return the exact bits you started with. The forward and inverse transforms are both well conditioned away from the poles, but they run in floating point, so the returned coordinates differ from the originals in the last few decimal places — on the order of nanometres of ground distance in the middle latitudes.
That is far below anything a dashboard renders or measures, so a single round trip is harmless. What is worth avoiding is making round trips part of a loop: reprojecting on every rerun, or storing the reprojected result and reprojecting it again on the next interaction, accumulates the error and, much more importantly, accumulates the cost, since a transform over a large frame is not cheap. Reproject once at the boundary, keep both representations if both are genuinely needed, and treat the geographic version as the source of truth that the others are derived from rather than as one of several equal copies that drift apart.
Jump to heading FAQ
Why does a polygon look bigger near the poles in Web Mercator?
Web Mercator is a conformal projection that preserves local angles by stretching the map vertically as latitude increases. The scale factor is 1 / cos(latitude), so at 60° a shape is exaggerated about twofold in each direction and roughly fourfold in area. This is a display property, not a data error, but it means you must never compute area directly in EPSG:3857.
What happens to points beyond 85° latitude when reprojecting to 3857?
Web Mercator is only defined up to about ±85.06°, where the projected y coordinate would otherwise go to infinity. Points beyond that latitude produce infinite or extreme values after to_crs. Clip or filter geometries to the valid band before reprojecting so a single polar vertex does not break the whole layer.
Is reprojecting 4326 to 3857 and back lossless?
Within the valid latitude band the round trip is accurate to floating-point tolerance, so an assert with a small tolerance passes. Loss only appears if geometries were clipped at the ±85.06° limit or if coordinates were rounded to reduce precision between the two transforms.
Back to CRS & Coordinate Systems Reference
Related
- Looking Up EPSG Codes for Your Region — pick the measurement CRS you reproject into for area and distance
- CRS & Coordinate Systems Reference — geographic vs projected, axis order, and the full EPSG table
- Dynamic Spatial Filtering — feeding the reprojected layer into a live, filterable map