Skill Tracer
Skills › Scientific Agent Skills › geopandas › crs-management.md

crs-management.md

238 lines · skills/geopandas/references/crs-management.md at 5f2bc03

Open on GitHub

Most marks come from matching words in the file, so treat them as pointers.

1# CRS, units, and reprojection
2
3A CRS is part of the data model, not display metadata. An incorrect or missing
4CRS can make numerically plausible results geographically wrong.
5

CRS, units, and reprojection

A CRS is part of the data model, not display metadata. An incorrect or missing CRS can make numerically plausible results geographically wrong.

Raw lines 1 to 5
6## Require authoritative CRS metadata
7
8```python
9from pyproj import CRS
11if gdf.crs is None:
12 raise ValueError("CRS is missing; recover it from authoritative source metadata")
14crs = CRS.from_user_input(gdf.crs)
15```
17Do not infer EPSG:4326 because values resemble longitude/latitude. Coordinate
18ranges are not evidence of datum, axis interpretation, units, or epoch.

Require authoritative CRS metadata

from pyproj import CRS

if gdf.crs is None:
    raise ValueError("CRS is missing; recover it from authoritative source metadata")

crs = CRS.from_user_input(gdf.crs)

Do not infer EPSG:4326 because values resemble longitude/latitude. Coordinate ranges are not evidence of datum, axis interpretation, units, or epoch.

Raw lines 6 to 19
20### `set_crs` versus `to_crs`
22```python
23# Assign metadata only; coordinate numbers do not change.
24gdf = gdf.set_crs("EPSG:4326")
26# Transform coordinates; the source CRS must already be set.
27projected = gdf.to_crs("EPSG:32631")
28```
30- Use `set_crs()` only when the coordinate values are already expressed in that
31 CRS and metadata is absent or demonstrably wrong.
32- Replacing existing metadata requires `allow_override=True`; record why.
33- Use `to_crs()` to transform the active geometry column. Transform each
34 additional geometry column explicitly.
35- Do not assign `gdf.crs = ...`; manual override is deprecated.

set_crs versus to_crs

# Assign metadata only; coordinate numbers do not change.
gdf = gdf.set_crs("EPSG:4326")

# Transform coordinates; the source CRS must already be set.
projected = gdf.to_crs("EPSG:32631")
  • Use set_crs() only when the coordinate values are already expressed in that CRS and metadata is absent or demonstrably wrong.
  • Replacing existing metadata requires allow_override=True; record why.
  • Use to_crs() to transform the active geometry column. Transform each additional geometry column explicitly.
  • Do not assign gdf.crs = ...; manual override is deprecated.
Raw lines 20 to 36
37## Axis order
39EPSG definitions can be latitude/longitude while GIS coordinate arrays are
40normally x/y (longitude/latitude). Inspect the authority axes:
42```python
43for axis in crs.axis_info:
44 print(axis.name, axis.abbrev, axis.direction, axis.unit_name)
45```
47For explicit pyproj transformations, request traditional GIS x/y order:
49```python
50from pyproj import Transformer
52transformer = Transformer.from_crs(
53 source_crs,
54 target_crs,
55 always_xy=True,
56 allow_ballpark=False,
57 only_best=True,
59x_out, y_out = transformer.transform(x_in, y_in, errcheck=True)
60```
62Record `always_xy=True`; it changes the API coordinate order, not the CRS
63definition. GeoParquet 1.1 explicitly stores WKB/native coordinates as x/y even
64when the CRS authority uses another axis order.

Axis order

EPSG definitions can be latitude/longitude while GIS coordinate arrays are normally x/y (longitude/latitude). Inspect the authority axes:

for axis in crs.axis_info:
    print(axis.name, axis.abbrev, axis.direction, axis.unit_name)

For explicit pyproj transformations, request traditional GIS x/y order:

from pyproj import Transformer

transformer = Transformer.from_crs(
    source_crs,
    target_crs,
    always_xy=True,
    allow_ballpark=False,
    only_best=True,
)
x_out, y_out = transformer.transform(x_in, y_in, errcheck=True)

Record always_xy=True; it changes the API coordinate order, not the CRS definition. GeoParquet 1.1 explicitly stores WKB/native coordinates as x/y even when the CRS authority uses another axis order.

Raw lines 37 to 65
66## Units and planar operations
68GeoPandas and Shapely compute planar Cartesian geometry and ignore Z:
70- geographic longitude/latitude axes are angular, usually degrees;
71- projected axes may be metres, US survey feet, feet, or another linear unit;
72- `.area` returns squared coordinate units;
73- `.length`, `.distance`, `.buffer`, `sjoin_nearest(max_distance=...)`,
74 `sjoin(predicate="dwithin", distance=...)`, precision grids, simplify
75 tolerances, and gap widths use coordinate units.
77```python
78axis_units = [(axis.unit_name, axis.unit_conversion_factor) for axis in crs.axis_info]
79if crs.is_geographic:
80 raise ValueError("Planar measurement on angular coordinates is not accepted")
81```
83Do not label an output metres merely because a CRS is projected. Convert units
84using the CRS axis metadata and document the conversion. Web Mercator
85(`EPSG:3857`) is for web display, not general area/distance analysis.

Units and planar operations

GeoPandas and Shapely compute planar Cartesian geometry and ignore Z:

  • geographic longitude/latitude axes are angular, usually degrees;
  • projected axes may be metres, US survey feet, feet, or another linear unit;
  • .area returns squared coordinate units;
  • .length, .distance, .buffer, sjoin_nearest(max_distance=...), sjoin(predicate="dwithin", distance=...), precision grids, simplify tolerances, and gap widths use coordinate units.
axis_units = [(axis.unit_name, axis.unit_conversion_factor) for axis in crs.axis_info]
if crs.is_geographic:
    raise ValueError("Planar measurement on angular coordinates is not accepted")

Do not label an output metres merely because a CRS is projected. Convert units using the CRS axis metadata and document the conversion. Web Mercator (EPSG:3857) is for web display, not general area/distance analysis.

Raw lines 66 to 86
87### Choosing a measurement CRS
89Choose based on the operation, study extent, datum, and required accuracy:
91- local UTM or another local conformal CRS for local distances/angles;
92- an equal-area CRS for area totals and areal normalization;
93- an equidistant/azimuthal design for a specified distance origin;
94- geodesic methods for large/global geographic extents.
96`estimate_utm_crs()` is a convenience based on dataset bounds, not a proof of
97suitability. It can be poor for multi-zone, polar, antimeridian-crossing, or
98very large datasets.

Choosing a measurement CRS

Choose based on the operation, study extent, datum, and required accuracy:

  • local UTM or another local conformal CRS for local distances/angles;
  • an equal-area CRS for area totals and areal normalization;
  • an equidistant/azimuthal design for a specified distance origin;
  • geodesic methods for large/global geographic extents.

estimate_utm_crs() is a convenience based on dataset bounds, not a proof of suitability. It can be poor for multi-zone, polar, antimeridian-crossing, or very large datasets.

Raw lines 87 to 99
100## Geodesic measurements
102When projection distortion is unacceptable, use the ellipsoid associated with
103the CRS through `pyproj.Geod`, not Shapely's planar distance:
105```python
106geod = crs.get_geod()
107azimuth_fwd, azimuth_back, metres = geod.inv(lon1, lat1, lon2, lat2)
108area_m2, perimeter_m = geod.geometry_area_perimeter(polygon)
109```
111Ensure inputs are longitude/latitude on the intended geodetic datum. Geodesic
112area is signed according to ring orientation and has documented limitations for
113very large polygons; normalize orientation and test known controls.

Geodesic measurements

When projection distortion is unacceptable, use the ellipsoid associated with the CRS through pyproj.Geod, not Shapely's planar distance:

geod = crs.get_geod()
azimuth_fwd, azimuth_back, metres = geod.inv(lon1, lat1, lon2, lat2)
area_m2, perimeter_m = geod.geometry_area_perimeter(polygon)

Ensure inputs are longitude/latitude on the intended geodetic datum. Geodesic area is signed according to ring orientation and has documented limitations for very large polygons; normalize orientation and test known controls.

Raw lines 100 to 114
115## Datum transformations and operation selection
117The same source/target CRS pair can have several operations. Selection depends
118on area of interest, installed grids, authority, and accuracy:
120```python
121from pyproj.aoi import AreaOfInterest
122from pyproj.transformer import TransformerGroup
123from pyproj import network
125network.set_network_enabled(False)
126group = TransformerGroup(
127 source_crs,
128 target_crs,
129 always_xy=True,
130 area_of_interest=AreaOfInterest(west, south, east, north),
131 allow_ballpark=False,
134if not group.best_available or not group.transformers:
135 raise RuntimeError("Best transformation unavailable; inspect missing grids")
137candidate = group.transformers[0]
138print(candidate.description, candidate.accuracy, candidate.area_of_use)
139```
141`AreaOfInterest` always takes geographic longitude/latitude **degrees**, never
142projected metres/feet. The bundled planner accepts `--bbox` only for a source
143CRS with geographic degree axes; omit it for projected sources. Its bounds and
144transform descriptions are planning metadata, not transformed coordinates.
146Privacy note: an exact area of interest can reveal a sensitive study location.
147Do not log it; retain only an approved coarse region or protected audit record.
149Operational rules:
1511. Set `allow_ballpark=False` for accuracy-sensitive work.
1522. Use `only_best=True` with `Transformer.from_crs()` when failure is preferable
153 to silently selecting a lower-quality operation.
1543. Verify `accuracy` (`-1` means unknown) and `area_of_use`.
1554. Inspect `TransformerGroup.unavailable_operations` for missing grids.
1565. Keep PROJ network disabled by default. pyproj wheels do not include all
157 transformation grids; downloading grids is a separate, explicit network and
158 supply-chain action.
1596. Record PROJ database/native versions and the selected operation description.
1607. For dynamic CRS, record coordinate epoch. A CRS alone may be insufficient.
162The bundled `scripts/crs_reprojection_plan.py` performs this inspection without
163transforming coordinates or enabling network access.

Datum transformations and operation selection

The same source/target CRS pair can have several operations. Selection depends on area of interest, installed grids, authority, and accuracy:

from pyproj.aoi import AreaOfInterest
from pyproj.transformer import TransformerGroup
from pyproj import network

network.set_network_enabled(False)
group = TransformerGroup(
    source_crs,
    target_crs,
    always_xy=True,
    area_of_interest=AreaOfInterest(west, south, east, north),
    allow_ballpark=False,
)

if not group.best_available or not group.transformers:
    raise RuntimeError("Best transformation unavailable; inspect missing grids")

candidate = group.transformers[0]
print(candidate.description, candidate.accuracy, candidate.area_of_use)

AreaOfInterest always takes geographic longitude/latitude degrees, never projected metres/feet. The bundled planner accepts --bbox only for a source CRS with geographic degree axes; omit it for projected sources. Its bounds and transform descriptions are planning metadata, not transformed coordinates.

Privacy note: an exact area of interest can reveal a sensitive study location. Do not log it; retain only an approved coarse region or protected audit record.

Operational rules:

  1. Set allow_ballpark=False for accuracy-sensitive work.
  2. Use only_best=True with Transformer.from_crs() when failure is preferable to silently selecting a lower-quality operation.
  3. Verify accuracy (-1 means unknown) and area_of_use.
  4. Inspect TransformerGroup.unavailable_operations for missing grids.
  5. Keep PROJ network disabled by default. pyproj wheels do not include all transformation grids; downloading grids is a separate, explicit network and supply-chain action.
  6. Record PROJ database/native versions and the selected operation description.
  7. For dynamic CRS, record coordinate epoch. A CRS alone may be insufficient.

The bundled scripts/crs_reprojection_plan.py performs this inspection without transforming coordinates or enabling network access.

Raw lines 115 to 164
Runs code scripts/crs_reprojection_plan.py :162
165## Geometry transformation caveat
167`GeoDataFrame.to_crs()` transforms every existing vertex. It does not interpret
168a segment as a geodesic arc:
170```python
171out = gdf.to_crs(target_crs)
172```
174If source linework is sparse, densify according to a documented geodesic or
175source-space tolerance before projection when shape fidelity matters. Validate
176the resulting topology and bounds.

Geometry transformation caveat

GeoDataFrame.to_crs() transforms every existing vertex. It does not interpret a segment as a geodesic arc:

out = gdf.to_crs(target_crs)

If source linework is sparse, densify according to a documented geodesic or source-space tolerance before projection when shape fidelity matters. Validate the resulting topology and bounds.

Raw lines 165 to 177
178## Antimeridian and projection boundaries
180`to_crs()` warns that objects crossing the dateline or another projection
181boundary have undesirable behavior. A naive line from 179°E to 179°W can be
182treated as spanning almost the whole map.
184Safe workflow:
1861. Normalize and validate longitude convention (`[-180, 180]` or `[0, 360)`).
1872. Detect segment jumps and bbox representations that cross the antimeridian.
1883. Split/unwrap at ±180° in a documented geographic CRS.
1894. Densify geodesic edges if required by the accuracy target.
1905. Transform each part with an operation valid for its area.
1916. Reassemble only when target topology permits it.
1927. Compare source/target control points, feature counts, validity, and bounds.
194For transformed bounds, use `Transformer.transform_bounds(..., densify_pts=...)`.
195When geographic output returns `right < left`, pyproj documents that the bounds
196cross the antimeridian and should be represented as two polygons. Do not sort
197the numbers and erase that meaning.

Antimeridian and projection boundaries

to_crs() warns that objects crossing the dateline or another projection boundary have undesirable behavior. A naive line from 179°E to 179°W can be treated as spanning almost the whole map.

Safe workflow:

  1. Normalize and validate longitude convention ([-180, 180] or [0, 360)).
  2. Detect segment jumps and bbox representations that cross the antimeridian.
  3. Split/unwrap at ±180° in a documented geographic CRS.
  4. Densify geodesic edges if required by the accuracy target.
  5. Transform each part with an operation valid for its area.
  6. Reassemble only when target topology permits it.
  7. Compare source/target control points, feature counts, validity, and bounds.

For transformed bounds, use Transformer.transform_bounds(..., densify_pts=...). When geographic output returns right < left, pyproj documents that the bounds cross the antimeridian and should be represented as two polygons. Do not sort the numbers and erase that meaning.

Raw lines 178 to 198
199## CRS equality and concatenation
201Compare semantic CRS objects, not raw WKT strings:
203```python
204left_crs = CRS.from_user_input(left.crs)
205right_crs = CRS.from_user_input(right.crs)
206if not left_crs.equals(right_crs):
207 right = right.to_crs(left_crs)
208```
210Equivalent does not mean equally appropriate for the analysis. Before concat,
211join, overlay, or clip, require matching CRS and confirm both datasets use the
212same coordinate epoch/realization where relevant.

CRS equality and concatenation

Compare semantic CRS objects, not raw WKT strings:

left_crs = CRS.from_user_input(left.crs)
right_crs = CRS.from_user_input(right.crs)
if not left_crs.equals(right_crs):
    right = right.to_crs(left_crs)

Equivalent does not mean equally appropriate for the analysis. Before concat, join, overlay, or clip, require matching CRS and confirm both datasets use the same coordinate epoch/realization where relevant.

Raw lines 199 to 213
214## Reprojection provenance
216Record:
218- source and target CRS as WKT2/PROJJSON plus authority IDs when available;
219- source of CRS assignment and any override;
220- axis order exposed by the CRS and API order (`always_xy`);
221- units and conversion factors;
222- area of interest at an approved precision;
223- chosen operation, expected accuracy, ballpark policy, and area of use;
224- required/available grids, network policy, PROJ data/database versions;
225- densification, antimeridian splitting, precision, and validation checks.

Reprojection provenance

Record:

  • source and target CRS as WKT2/PROJJSON plus authority IDs when available;
  • source of CRS assignment and any override;
  • axis order exposed by the CRS and API order (always_xy);
  • units and conversion factors;
  • area of interest at an approved precision;
  • chosen operation, expected accuracy, ballpark policy, and area of use;
  • required/available grids, network policy, PROJ data/database versions;
  • densification, antimeridian splitting, precision, and validation checks.
Raw lines 214 to 226
227## Sources (verified 2026-10-01)
229- [GeoPandas projections guide](https://geopandas.org/en/stable/docs/user_guide/projections.html).
230- [GeoDataFrame.to_crs](https://geopandas.org/en/stable/docs/reference/api/geopandas.GeoDataFrame.to_crs.html).
231- [GeoDataFrame.set_crs](https://geopandas.org/en/stable/docs/reference/api/geopandas.GeoDataFrame.set_crs.html).
232- [pyproj Transformer API](https://pyproj4.github.io/pyproj/stable/api/transformer.html).
233- [pyproj CRS API](https://pyproj4.github.io/pyproj/stable/api/crs/crs.html).
234- [pyproj transformation grids](https://pyproj4.github.io/pyproj/stable/transformation_grids.html).
235- [pyproj Geod API](https://pyproj4.github.io/pyproj/stable/api/geod.html).
236- [GeoParquet 1.1.0 CRS and axis-order rules](https://geoparquet.org/releases/v1.1.0/).
238- [pyproj 3.8.0 AreaOfInterest source](https://github.com/pyproj4/pyproj/blob/3.8.0/pyproj/aoi.py).