GeoPandas vs Standard Pandas for Spatial Data: Safe Coordinate Conversion and CRS Handling
A pandas DataFrame with lon and lat columns holds coordinates but knows nothing about geometry, so the moment you attempt a spatial join, buffer, or distance you get a TypeError or — worse — a silently wrong answer. This guide shows the exact conversion to a spatially-aware structure and where it goes wrong; it is for anyone moving tabular point data into GeoPandas DataFrames under Mastering Core Geospatial Python Libraries.
points_from_xy, then attach a CRS — after which spatial joins, buffers and distances become meaningful.Why This Approach / What Goes Wrong
A standard pandas DataFrame treats lon and lat as two independent float64 columns. Nothing ties them together into a location, nothing records which coordinate reference system they live in, and there is no spatial index to answer "which points fall inside this polygon". Pandas is a tabular engine; it will happily compute df["lat"].mean() but has no concept of a point on the Earth.
That gap surfaces the moment you try anything spatial. Feeding raw columns to gpd.sjoin or shapely predicates raises a TypeError because there is no geometry to operate on. The more dangerous case is arithmetic that runs but is meaningless: computing a Euclidean "distance" straight from degree columns treats one degree of longitude as if it were one degree of latitude, which is false everywhere except the equator, so the number you get is not a distance in any unit.
cos(latitude) while latitude degrees stay near-constant, which is why Euclidean arithmetic on raw lon/lat columns returns a unitless number rather than a distance.The fix is to promote the frame to a GeoDataFrame with two things attached — a geometry column built from the coordinates, and a CRS declaring what those coordinates mean. From there you can apply vectorized predicates through Shapely Geometry Operations, and reproject correctly with the definitions in Coordinate Systems with PyProj. The single most common failure is a CRS mismatch: a spatial join between two layers whose CRS disagree evaluates every predicate as false and returns all-NaN, so aligning projections up front — the subject of Coordinate Reference System Transformations — is not optional. Note too that raw lon/lat from a CSV is in EPSG:4326, which is a geographic CRS in degrees; any metric operation (distance, area, buffer) must first project to a suitable metric CRS such as a local UTM zone, never Web Mercator (EPSG:3857), whose distances are distorted away from the equator.
The conversion is not free, and knowing its price is what tells you when to skip it. Two float64 columns cost 16 bytes per row. Replacing them with a geometry column stores a Python object per row, each wrapping a GEOS geometry allocated on the C heap, which lands closer to 100–130 bytes per point — call it an eight- to tenfold increase for the coordinate portion of the frame, and considerably worse if you were storing coordinates as float32. gdf.memory_usage(deep=True) will show you the real figure for your data. The practical consequence is that a 50-million-row point table that fits comfortably in pandas may not fit as a GeoDataFrame on the same machine.
So the honest answer to "GeoPandas or pandas?" is not always GeoPandas. Keep raw coordinate columns when the work is genuinely tabular — filtering, grouping, joining on an identifier, computing statistics per site — and build geometry only for the stage that needs a spatial predicate, on the subset that reaches it. Attribute-heavy pipelines that touch geometry once at the end are measurably cheaper this way, and the pattern degrades gracefully: the geometry column is derived, so you can always rebuild it. What you must not do is the reverse — approximate a spatial operation with arithmetic on degree columns because converting felt expensive.
Prerequisites
geopandas>=0.14shapely>=2.0pandas>=2.0pyproj>=3.4
conda install -c conda-forge "geopandas=0.14.*" "shapely=2.0.*" "pandas=2.0.*" "pyproj=3.4.*"
Step-by-Step Implementation
1. Start from the raw tabular data. A typical source is a CSV of sensor readings with plain longitude and latitude columns — a pure pandas object with no spatial awareness.
import pandas as pd
air_quality_sensors = pd.DataFrame({
"sensor_id": [1, 2, 3, 4],
"site": ["Manhattan", "Los Angeles", "London", "Tokyo"],
"pm25": [8.4, 12.1, 9.7, 14.2],
"lon": [-73.9857, -118.2437, -0.1276, 139.6917],
"lat": [40.7484, 34.0522, 51.5074, 35.6895],
})
2. Build the geometry column with a vectorized constructor. gpd.points_from_xy() turns the two numeric columns into an array of Shapely Point objects without any row-wise Python loop. Note the argument order is (x, y) — longitude first, then latitude. Reversing them silently places every point in the wrong hemisphere.
import geopandas as gpd
point_geometry = gpd.points_from_xy(
air_quality_sensors["lon"], # x = longitude
air_quality_sensors["lat"], # y = latitude
)
3. Assemble the GeoDataFrame and declare the CRS. Passing crs="EPSG:4326" records that the coordinates are WGS 84 lon/lat in degrees. This metadata is what every downstream spatial operation reads; omit it and libraries assume an undefined coordinate space.
sensors_gdf = gpd.GeoDataFrame(
air_quality_sensors,
geometry=point_geometry,
crs="EPSG:4326",
)
4. Project to a metric CRS before any distance or area work. Geographic degrees cannot be measured in metres, so reproject to an appropriate projected CRS first. For a dataset spanning multiple UTM zones, pick per-region zones (see choosing a UTM zone automatically); for a single-region example, an explicit metric CRS is fine.
# Distances/areas require metres — reproject out of EPSG:4326 first.
sensors_utm = sensors_gdf.to_crs("EPSG:32618") # UTM zone 18N (NYC area)
buffered = sensors_utm.geometry.buffer(500) # 500-metre buffers, now meaningful
5. Keep the frame spatial through ordinary pandas operations. This is where most pipelines quietly lose their geometry. A GeoDataFrame is a DataFrame subclass, so every pandas method still works — but several of them return a plain DataFrame, and the demotion is silent until something downstream raises AttributeError: 'DataFrame' object has no attribute 'geometry' several steps later.
import pandas as pd
import geopandas as gpd
# Column selection that omits geometry -> plain DataFrame
attrs_only = sensors_gdf[["sensor_id", "pm25"]]
print(type(attrs_only).__name__) # DataFrame
# groupby().agg() cannot aggregate geometry, so it drops out -> DataFrame
by_site = sensors_gdf.groupby("site")["pm25"].mean()
# merge() keeps GeoDataFrame-ness only when the LEFT operand is the spatial one
readings = pd.DataFrame({"sensor_id": [1, 2, 3, 4], "calibrated": [8.1, 12.4, 9.5, 14.0]})
good = sensors_gdf.merge(readings, on="sensor_id") # GeoDataFrame
bad = readings.merge(sensors_gdf, on="sensor_id") # DataFrame — geometry is now a dumb column
# Restore it explicitly rather than hoping
restored = gpd.GeoDataFrame(bad, geometry="geometry", crs=sensors_gdf.crs)
assert restored.crs.to_epsg() == 4326
Three rules cover almost every case. Put the spatial frame on the left of a merge. Use dissolve() rather than groupby() when the aggregation should also merge geometry. And after any operation you are unsure about, call set_geometry() — or rebuild with the GeoDataFrame constructor — passing the CRS explicitly, because a frame reconstructed from a demoted DataFrame has no CRS even when its geometry objects are intact. pd.concat deserves its own note: concatenating two GeoDataFrames whose CRS disagree raises ValueError: Cannot determine common CRS for concatenation inputs rather than picking one, which is the correct behaviour and the reason to align with to_crs() before stacking layers.
6. Drop back to plain columns when the destination is tabular. The conversion runs in both directions, and going back is often the right move before writing to a warehouse, a CSV export, or any consumer that has no geometry type. GeoPandas 0.14 and later expose get_coordinates(), which flattens a whole geometry column into a numeric frame in one vectorized call.
# Vectorized extraction — one call, no Python loop over geometries
coords = sensors_gdf.get_coordinates() # columns: x, y (index aligns with the frame)
flat = sensors_gdf.drop(columns="geometry").join(coords)
# For points only, the scalar accessors are equivalent and slightly more direct
flat["lon"] = sensors_gdf.geometry.x
flat["lat"] = sensors_gdf.geometry.y
# Portable binary form when the target column type is bytes
wkb_column = gpd.GeoSeries(sensors_gdf.geometry).to_wkb()
get_coordinates() returns one row per vertex, not per feature, so on lines and polygons the result is longer than the input frame — pass index_parts=True when you need to know which vertex belongs to which feature. For points the row counts match, which is what makes the join above safe. Whichever form you export, record the EPSG code alongside it: a bare pair of numeric columns has lost the CRS, and that is exactly the state this guide started by fixing.
Verification
Confirm the conversion actually produced a spatial object with a known CRS and valid point geometries. Every assertion below should pass silently; the print statements show the expected output as comments.
from shapely.geometry import Point
# Type and geometry checks
assert isinstance(sensors_gdf, gpd.GeoDataFrame)
assert sensors_gdf.geometry.geom_type.eq("Point").all()
assert sensors_gdf.geometry.notna().all() # no empty geometries
# CRS is present and correct
assert sensors_gdf.crs is not None
assert sensors_gdf.crs.to_epsg() == 4326
print(sensors_gdf.crs.name)
# -> WGS 84
print(sensors_gdf.geometry.iloc[0])
# -> POINT (-73.9857 40.7484)
print(sensors_gdf.total_bounds)
# -> [-118.2437 34.0522 139.6917 51.5074]
The bounds assertion is the one worth keeping in CI, because it is the only check here that catches a swapped coordinate pair. Real latitudes cannot exceed ±90, so a swap pushes an impossible value into the y slot and the assertion fails immediately — whereas type, CRS and non-null checks all pass happily on data that is in the wrong hemisphere.
minx, miny, maxx, maxy = sensors_gdf.total_bounds
# Structural: latitudes are bounded, longitudes are bounded, and a swap breaks the first
assert -90 <= miny <= maxy <= 90, "y values are not latitudes — arguments were swapped"
assert -180 <= minx <= maxx <= 180, "x values are outside the valid longitude range"
# Domain: the extent should match the region the dataset claims to cover
assert maxy - miny < 60, "extent is wider than expected for this dataset"
print("bounds OK")
# -> bounds OK
Edge Cases & Debugging
- NaN coordinates become empty points.
points_from_xy()maps a missinglonorlattoPOINT (nan nan), which corruptstotal_boundsand every predicate. Drop them first:air_quality_sensors.dropna(subset=["lon", "lat"]). - Out-of-range values pass silently. Coordinates outside
[-180, 180]or[-90, 90]still construct a point but sit nowhere real. Guard with a bounds mask —df["lon"].between(-180, 180) & df["lat"].between(-90, 90)— before building geometry. - CRS compared with
==instead of.equals().gdf.crs == other.crsuses object equality and can returnFalsefor the same CRS from different sources; usegdf.crs.equals(other.crs). A mismatch here is what produces an all-NaNspatial join. - Swapped x/y from an axis-order assumption. Some pipelines emit lat/lon order.
points_from_xyalways wants(lon, lat); if points land in the ocean, check the argument order and anyalways_xyhandling in your transformer. - Repeated reprojection on large frames.
to_crs()rebuilds every geometry, so calling it inside a loop is a hidden bottleneck. Project once at pipeline entry and reuse the result; for choosing between GeoPandas and streaming I/O on very large inputs, see GeoPandas vs Fiona for Large Files. AttributeError: 'DataFrame' object has no attribute 'geometry'. An upstream pandas operation demoted the frame — usually a column selection, agroupby().agg(), or amergewith the spatial frame on the right. Find the step that returned a plainDataFrameand re-wrap withset_geometry("geometry")plus an explicitcrs=.- Coordinates read from CSV as strings. A column containing
"40.7484"with a stray space, a thousands separator, or a comma decimal mark loads asobjectdtype, andpoints_from_xywill raise or coerce unpredictably. Force the type at read time withpd.read_csv(..., dtype={"lon": "float64", "lat": "float64"})and let it fail loudly on the bad rows. unary_unionwarns or disappears. GeoPandas 1.0 renamed it tounion_all()and made pyogrio the default I/O engine at the same release. Pingeopandas>=1.0and use the new name; the old attribute still exists in the 1.x line but emits a deprecation warning.- A frame round-tripped through pickle loses its CRS. Pickling a demoted
DataFramepreserves the geometry objects but not theGeoDataFramemetadata. Persist to GeoPackage or GeoParquet instead, both of which store the CRS in the file itself.
Frequently Asked Questions
Is there ever a good reason to keep lat/lon as plain pandas columns?
Yes, and it is mostly about volume and stage. Coordinates as two float64 columns cost roughly a tenth of the memory of a geometry column and serialize to Parquet, CSV, or a database with no special handling, so a pipeline that ingests hundreds of millions of point records, filters them on attributes, and only then needs a spatial predicate is cheaper if geometry is built last, on the survivors. The rule that keeps this safe is that plain columns are for storage and tabular work only — the moment a question is spatial, convert, rather than approximating with arithmetic on degrees.
Should I use points_from_xy or GeoDataFrame.from_features?
points_from_xy when the source is tabular — a CSV, a database query, an API returning flat records — because it is a single vectorized call over two numeric columns. from_features when the source is already GeoJSON-shaped, since it parses the full geometry types and property dictionaries that points_from_xy cannot represent. Reaching for from_features on tabular data means constructing a dictionary per row, which is orders of magnitude slower for no benefit; reaching for points_from_xy on GeoJSON means silently discarding every non-point geometry.
Why does my spatial join return all NaN when both layers clearly overlap?
Almost always a CRS mismatch rather than a geometry problem. sjoin evaluates predicates in the coordinate space of the left frame, and if one layer is in degrees while the other is in metres, no predicate can ever be true — the numbers are five orders of magnitude apart. Check with left.crs.equals(right.crs) rather than ==, align with to_crs(), and only then look at the geometry. The second-most-common cause is a swapped coordinate pair placing one layer in the wrong hemisphere, which the bounds assertion under Verification catches.
Does converting to a GeoDataFrame slow down my ordinary pandas work?
Not measurably, as long as you leave the geometry column out of the hot path. Filtering, grouping and joining on attribute columns run at the same speed because they never touch the geometry array. What does cost you is anything that copies the frame — .copy(), a concat, a sort — since the geometry objects are copied with it, and any operation that iterates geometries in Python. Keep bulk attribute work vectorized and geometry operations vectorized, and the overhead stays in the memory footprint rather than in the runtime.
How do I know whether a variable is still spatial before something breaks?
Assert it at the boundaries rather than inspecting interactively. isinstance(frame, gpd.GeoDataFrame) catches the demotion, frame.crs is not None catches the metadata loss, and the pair together is a two-line guard worth putting at the top of any function that expects to receive a spatial frame. Doing this at function entry rather than at the point of use turns a confusing AttributeError deep in a call stack into a clear failure at the step that actually broke the invariant.