EPSG Codes Explained: How to Choose the Right CRS in Python

On this page

Problem statement

EPSG:4326, EPSG:3857, EPSG:32633: an EPSG code is a number in a public registry that stands for a complete coordinate reference system — datum, ellipsoid, projection, units, axis order. Code reads and writes them constantly, so two practical questions come up again and again: what does a given code actually define, and which code should a dataset for a given place be in?

This page answers both for Vienna. It asks the EPSG registry, through pyproj, which projected systems cover the city, and then measures the city's 23 districts in four of them against the city's own published areas — one of which reproduces the official figures to the square metre.

Quick answer

An EPSG code is a key into the EPSG registry; pyproj.CRS.from_epsg(code) gives the full definition behind it, and gdf.crs.to_epsg() goes the other way. To choose one, ask the registry what covers your area, then prefer the system your data's publisher uses: measured on Vienna, that one matched the official areas exactly, a UTM zone was 0.06% off, and Web Mercator more than doubled them.

Check the code, read its definition, ask the registry, measure

Common EPSG codes with their units, extent, and best use. the codes worth memorisingEPSGunitsextentuse it for4326degreesworldstorage, exchange3857metres*worldweb basemaps4258degreesEuropeEU storage3035metresEuropeEU area stats27700metresBritainUK measurementUTM zonemetresone zonelocal accuracy* 3857 metres are distorted away from the equator — never measure in itPick by task and extent, not by what the file happened to arrive in spatialworkflow.io
Six codes cover the great majority of everyday Python GIS work.

Step 1: Check the current CRS of your data

Always inspect the CRS first.

import geopandas as gpd

gdf = gpd.read_file("data/city_parks.geojson")
print(gdf.crs)
EPSG:4326

A code, a full WKT definition with no code, or None — three different situations, the last of which is cannot transform naive geometries.

Step 2: Read what the code defines

The code is a short name; the definition behind it is what PROJ uses:

import pyproj

for code in (4326, 3857, 32633, 31256):
    crs = pyproj.CRS.from_epsg(code)
    axes = ", ".join(f"{a.abbrev} {a.direction} ({a.unit_name})" for a in crs.axis_info)
    print(f"EPSG:{code:<6} {crs.name:28} {crs.datum.name:42} {axes}")
EPSG:4326   WGS 84                       World Geodetic System 1984 ensemble        Lat north (degree), Lon east (degree)
EPSG:3857   WGS 84 / Pseudo-Mercator     World Geodetic System 1984 ensemble        X east (metre), Y north (metre)
EPSG:32633  WGS 84 / UTM zone 33N        World Geodetic System 1984 ensemble        E east (metre), N north (metre)
EPSG:31256  MGI / Austria GK East        Militar-Geographische Institut             X north (metre), Y east (metre)

Two of the four put north first. EPSG:4326 is officially latitude then longitude, and MGI / Austria GK East calls its northing X and its easting Y. GeoPandas and Shapely always treat x as east, so this rarely shows in Python; a tool that follows the registry literally, such as a WMS 1.3 server, does it the registry's way. The datum line matters too: three of the four share WGS 84, and 31256 sits on the Austrian MGI datum, so converting to or from it is a datum shift as well as a projection.

What four EPSG codes define: datum, first axis and unit. what the code stands fordatumfirst axisunitEPSG:4326WGS 84!latitude, northdegreeEPSG:3857WGS 84x, eastmetreEPSG:32633WGS 84eastingmetreEPSG:31256MGI (Austria)!X = northingmetreFrom pyproj.CRS.from_epsg(); GeoPandas always plots x as east spatialworkflow.io
Two of the four put north first, and one is on a different datum — none of which the number itself shows.

Step 3: Ask the registry what covers your area

The EPSG database knows every system's area of use, so the candidates for a place can be listed rather than remembered:

from pyproj.aoi import AreaOfInterest
from pyproj.database import query_crs_info
from pyproj.enums import PJType

districts = gpd.read_file("data/districts.geojson")
w, s, e, n = districts.total_bounds
found = query_crs_info(auth_name="EPSG", pj_types=PJType.PROJECTED_CRS, contains=True,
                       area_of_interest=AreaOfInterest(w, s, e, n))
print(len(found), "projected EPSG systems cover all of Vienna")
austrian = [c for c in found if "Austria" in c.name]
print(len(austrian), "of them Austrian, e.g.", [f"{c.code} {c.name}" for c in austrian if c.code in ("31256", "3416")])
print([f"{c.code} {c.name}" for c in found if c.code in ("32633", "3035", "3857")])
50 projected EPSG systems cover all of Vienna
9 of them Austrian, e.g. ['3416 ETRS89 / Austria Lambert', '31256 MGI / Austria GK East']
['3035 ETRS89-extended / LAEA Europe', '3857 WGS 84 / Pseudo-Mercator', '32633 WGS 84 / UTM zone 33N']

Fifty projected systems in the registry cover the whole city. Nine are Austrian, among them Austria Lambert (EPSG:3416) and the national grid the city itself publishes in, MGI / Austria GK East (EPSG:31256); the rest are wider systems such as LAEA Europe, UTM zone 33N and Web Mercator, whose area of use is the world. That is a list of candidates, not an answer; Example 5 measures four of them against the city's own figures.

Step 4: Reproject data when needed

Measure the districts in UTM zone 33N against the city's own area column, FLAECHE:

import geopandas as gpd

districts = gpd.read_file("data/districts.geojson")
print(districts.crs)

districts_m = districts.to_crs("EPSG:32633")
districts_m["area_sq_m"] = districts_m.area

print(districts_m[["NAMEK", "area_sq_m", "FLAECHE"]].head(3).round(0).to_string(index=False))
EPSG:4326
       NAMEK  area_sq_m    FLAECHE
Innere Stadt  2867180.0  2868772.0
   Simmering 23243928.0 23256174.0
      Neubau  1607341.0  1608246.0

FLAECHE is the city's own figure for each district. UTM zone 33N lands within a few hundredths of a percent of it. If you calculate area directly in EPSG:4326, the results are in square degrees — all 23 districts of Vienna add up to 0.05 — and in Web Mercator they come out 2.25 times too large (Example 5).

Step 5: Verify the result before analysis

After assigning or transforming CRS, check that the result makes sense.

import geopandas as gpd

roads = gpd.read_file("data/roads.shp")
parcels = gpd.read_file("data/census_districts.shp")

print(roads.crs)
print(parcels.crs)

parcels = parcels.to_crs(roads.crs)

print(roads.total_bounds.round(4))
print(parcels.total_bounds.round(4))
EPSG:4326
EPSG:31256
[16.1874 48.121  16.5501 48.3214]
[16.1818 48.1179 16.5775 48.3227]

The bounds of both layers now fall around 16.2–16.6°E and 48.1–48.3°N, which is Vienna.

Vienna's districts measured in four CRSs

The data used on this page

Vienna, from the city's open data WFS: the 23 district boundaries with the city's own area for each, the 1,054 park locations, the 2,308 public drinking fountains, the 29,658 edges of the street graph, and the 250 census districts (Zählbezirke). Vienna sits in UTM zone 33N, which is why EPSG:32633 appears throughout. The census districts are saved in MGI / Austria GK East (EPSG:31256), the national CRS the city itself uses; the fountains are also saved once as a shapefile with its .prj deleted.

import os

import geopandas as gpd
import requests

WFS = "https://data.wien.gv.at/daten/geo"


def wien(layer):
    reply = requests.get(WFS, timeout=180, params={
        "service": "WFS", "request": "GetFeature", "version": "1.1.0",
        "typeName": f"ogdwien:{layer}", "srsName": "EPSG:4326", "outputFormat": "json"})
    reply.raise_for_status()
    return gpd.read_file(reply.text)


os.makedirs("data", exist_ok=True)
if not os.path.exists("data/districts.geojson"):
    wien("BEZIRKSGRENZEOGD")[["NAMEK", "BEZNR", "FLAECHE", "geometry"]].to_file("data/districts.geojson")
    wien("PARKINFOOGD")[["ANL_NAME", "BEZIRK", "geometry"]].to_file("data/city_parks.geojson")
    fountains = wien("TRINKBRUNNENOGD")[["BASIS_TYP_TXT", "geometry"]]
    fountains.to_file("data/gps_points.geojson")
    fountains.to_file("data/fountains_noprj.shp")
    os.remove("data/fountains_noprj.prj")
    wien("STRASSENGRAPHOGD")[["FEATURENAME", "EDGECATEGORY", "geometry"]].to_file("data/roads.shp")
    wien("ZAEHLBEZIRKOGD")[["ZBEZ", "BEZNR", "FLAECHE", "geometry"]].to_crs(31256).to_file(
        "data/census_districts.shp")
for name in sorted(os.listdir("data")):
    if name.endswith((".geojson", ".shp")):
        print(f"{name:24} {len(gpd.read_file('data/' + name)):>6,} features")
census_districts.shp        250 features
city_parks.geojson        1,054 features
districts.geojson            23 features
fountains_noprj.shp       2,308 features
gps_points.geojson        2,308 features
roads.shp                29,658 features

Example 1: Read a file and inspect its CRS

import geopandas as gpd

buildings = gpd.read_file("data/census_districts.shp")
print("CRS:", buildings.crs, "-", buildings.crs.name)
CRS: EPSG:31256 - MGI / Austria GK East

This confirms whether CRS metadata exists — here a national projected CRS rather than a UTM zone or EPSG:4326.

Example 2: Assign a missing EPSG code to data

import geopandas as gpd

points = gpd.read_file("data/fountains_noprj.shp")
print("as read:", points.crs, points.total_bounds.round(3))

if points.crs is None:
    points = points.set_crs("EPSG:4326")

print(points.crs)
print("the GeoJSON copy:", gpd.read_file("data/gps_points.geojson").crs)
as read: None [16.184 48.123 16.545 48.317]
EPSG:4326
the GeoJSON copy: EPSG:4326

Use this only when the coordinates are already in that CRS and the metadata is missing — here the bounds are plainly degrees around Vienna, 16.2–16.6°E and 48.1–48.3°N. The GeoJSON copy never needs it: GeoJSON is defined as WGS 84, so GDAL gives it EPSG:4326 whatever the file says, and a GeoJSON written in another CRS without its legacy crs member comes back wrongly labelled rather than unlabelled.

Example 3: Reproject data to a projected CRS for analysis

import geopandas as gpd

points = gpd.read_file("data/gps_points.geojson")

if points.crs is None:
    points = points.set_crs("EPSG:4326")

points_utm = points.to_crs("EPSG:32633")

points_utm["buffer_500m"] = points_utm.buffer(500)
print(points_utm.crs, f"one buffer: {points_utm['buffer_500m'].iloc[0].area:,.0f} m²")
EPSG:32633 one buffer: 784,137 m²

A circle of radius 500 m has an area of 785,398 m²; the buffer is a 64-sided polygon, so it comes out slightly smaller. The same buffer(500) in EPSG:4326 would be a 500-degree circle.

This is the correct pattern when you need meter-based analysis.

Example 4: Confirm layer alignment after reprojection

import geopandas as gpd

rivers = gpd.read_file("data/roads.shp")
catchments = gpd.read_file("data/census_districts.shp")
print("before:", catchments.total_bounds.round(0))

catchments_aligned = catchments.to_crs(rivers.crs)

print("Roads bounds:            ", rivers.total_bounds.round(4))
print("Census districts bounds: ", catchments_aligned.total_bounds.round(4))
joined = gpd.sjoin(rivers, catchments_aligned, predicate="intersects")
print(f"{joined.index.nunique():,} of {len(rivers):,} road edges touch a census district")
before: [-11180. 331051.  18258. 353820.]
Roads bounds:             [16.1874 48.121  16.5501 48.3214]
Census districts bounds:  [16.1818 48.1179 16.5775 48.3227]
29,650 of 29,658 road edges touch a census district

If the bounds now fall in the same region and a join finds matches, the reprojection likely worked. Before it, the census districts' bounds are in metres east and north of the Austrian grid origin, which no amount of looking at a map in degrees would line up with.

Example 5: The same districts measured in four CRSs

import geopandas as gpd

districts = gpd.read_file("data/districts.geojson")
official = districts["FLAECHE"].sum()
print(f"official area of Vienna's 23 districts: {official:,.0f} m²")
for code in ("EPSG:4326", "EPSG:3857", "EPSG:32633", "EPSG:31256"):
    area = districts.to_crs(code).area
    worst = (area / districts["FLAECHE"] - 1).abs().max()
    print(f"{code:10} total {area.sum():>16,.4f}   {area.sum() / official:8.5f} × official"
          + (f"   worst district off by {worst:.3%}" if code != "EPSG:4326" else "   (square degrees)"))
print("estimate_utm_crs():", districts.estimate_utm_crs())
official area of Vienna's 23 districts: 414,871,121 m²
EPSG:4326  total           0.0502    0.00000 × official   (square degrees)
EPSG:3857  total 933,444,696.6628    2.24996 × official   worst district off by 125.633%
EPSG:32633 total 414,642,193.6182    0.99945 × official   worst district off by 0.060%
EPSG:31256 total 414,871,121.5160    1.00000 × official   worst district off by 0.000%
estimate_utm_crs(): EPSG:32633

EPSG:31256 reproduces the city's figures to the square metre because it is the CRS they were computed in. UTM zone 33N is within 0.06% for every district — the scale factor of a transverse Mercator zone, not an error. Web Mercator more than doubles every area at Vienna's latitude.

The area of Vienna’s 23 districts computed in three projected CRSs, as a share of the city’s official figure. Vienna’s area, measured in three CRSsEPSG:3857 Web Mercator225.0%EPSG:32633 UTM 33N99.945%EPSG:31256 GK East100.000%0100%200%Official total 414,871,121 m²; in EPSG:4326 the same polygons measure 0.05 square degrees spatialworkflow.io
The national CRS the figures were computed in matches exactly; UTM is within 0.06%; Web Mercator more than doubles.

Why the publisher's code wins

The same country outline drawn in three coordinate reference systems. the same shape, three CRSsEPSG:4326area in deg²degrees — stretchedEPSG:3857scale variesmetres, distortedEPSG:27700area in m²metres, true locallyA projection is a choice about which distortion you can live with spatialworkflow.io
Every flat map distorts something; the EPSG code you pick decides what.

The EPSG registry, maintained by the oil and gas industry's geomatics committee (IOGP), assigns a number to each datum, ellipsoid, projection and complete CRS, and PROJ ships a copy of it. A code is therefore not a description but a lookup: two files that both say EPSG:31256 agree on every parameter, and a file that carries only WKT may describe the same system without saying so — to_epsg() recovers the code when it can.

For choosing among the candidates, Example 5 is the argument. Vienna's district areas in FLAECHE were computed by the city in MGI / Austria GK East, EPSG:31256, and in that system GeoPandas reproduces them to the square metre. UTM zone 33N, a perfectly good metric system, is 0.06% off for every district: not an error, the zone's scale factor. Web Mercator is 2.25 times too large. When the data's publisher used a national system, measuring in it is the only way to match their numbers exactly; when no such system applies, a UTM zone is the reasonable default (choosing a projected CRS).

WKT-only files, ESRI codes and Web Mercator

  • A file with WKT and no code is not missing a CRS. gdf.crs.to_epsg() finds the matching code when one exists; to_epsg(min_confidence=…) controls how close the match must be.
  • ESRI: codes are a separate registry; pyproj.CRS("ESRI:102003") reads them, and they have no EPSG number.
  • EPSG:3857 has a world-wide area of use and appears for every query; it is for web tiles, not measurement.

To understand the broader concept, see Coordinate Reference Systems (CRS) Explained for Python GIS.

For related tasks, read GeoPandas Basics: Working with Spatial Data in Python and Python for GIS: What It Is and When to Use It.

FAQ

What is the difference between an EPSG code and a CRS?

A CRS is the full coordinate reference system definition. An EPSG code is a standard identifier for one specific CRS.

When should I use EPSG:4326 in Python GIS?

Use EPSG:4326 for latitude/longitude data, GPS data, GeoJSON, and many API or storage workflows. It is usually not the best choice for distance or area analysis.

How do I find the right UTM EPSG code for my area?

Pick the UTM zone that covers your data's longitude and hemisphere; for example EPSG:32633 is UTM zone 33N (northern hemisphere) and EPSG:32733 is the matching zone 33S. You can also let GeoPandas suggest one with gdf.estimate_utm_crs(), which returns the appropriate UTM CRS for the layer's extent.

What does EPSG:3857 actually mean and when should I avoid it?

EPSG:3857 is Web Mercator, a projected CRS in metres used by most web basemaps like OpenStreetMap and Google Maps. Avoid it for distance and area work, because its scale distortion grows sharply toward the poles, so measurements are unreliable away from the equator.

Which EPSG code should I use for the US or the UK?

For continental US analysis, EPSG:5070 (NAD83 CONUS Albers, metres) is a good equal-area choice for national distance and area work. For Great Britain, use EPSG:27700 (British National Grid, metres), the standard projected CRS for UK data.

Does changing the EPSG code with set_crs move my data on the map?

No. set_crs() only attaches metadata and assumes the coordinates already match that CRS, so it never moves the geometry. If you assign the wrong code with set_crs(), the data will plot in the wrong place; use to_crs() when you actually want to transform coordinates.