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.

Converting a plain pandas DataFrame into a GeoDataFrame On the left a pandas DataFrame stores longitude and latitude as plain float64 columns with no geometry or CRS, so a degree-based distance is meaningless. Two transform steps — points_from_xy builds the geometry, and constructing a GeoDataFrame with crs EPSG:4326 attaches the coordinate reference system — produce the GeoDataFrame on the right, which carries a geometry column and CRS so spatial joins, buffers and distances become valid. A footnote notes that metric work still needs a reprojection to a projected CRS such as EPSG:32618. pandas.DataFrame site lon lat NYC -73.99 40.75 LA -118.24 34.05 lon / lat = plain float64 no geometry · no CRS attached degree "distance" is meaningless 1° lon ≠ 1° lat except at the equator points_from_xy(lon, lat) build the geometry column GeoDataFrame(crs="EPSG:4326") attach the coordinate reference system GeoDataFrame site geometry NYC POINT (-73.99 40.75) LA POINT (-118.24 34.05) .crs = EPSG:4326 geometry column + CRS metadata sjoin · buffer · distance now valid Metric work? .to_crs("EPSG:32618") first — EPSG:4326 is degrees, not metres.
The jump from pandas to GeoPandas is two steps: build a geometry column with 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.

Ground distance covered by one degree of longitude at four latitudes Four horizontal bars show how far one degree of longitude reaches on the ground: about 111.3 km at the equator, 92.4 km at Los Angeles (34.1 degrees north), 84.3 km at Manhattan (40.7 degrees north) and 69.4 km at London (51.5 degrees north). A dashed reference line marks the equator reach so the shortfall of each higher-latitude bar is visible. A side panel contrasts this with one degree of latitude, which stays close to 110.6 km everywhere, and a footer warns that combining the two in a Euclidean formula yields a number with no unit. A degree of longitude is not a fixed ground distance equator reach equator · 0° Los Angeles · 34.1° N Manhattan · 40.7° N London · 51.5° N 111.3 km 92.4 km 84.3 km 69.4 km ≈ 17% shorter ≈ 24% shorter ≈ 38% shorter than at the equator 1° of latitude ≈ 110.6 km at every latitude 1° of longitude 110.6 × cos φ shrinks toward the poles √(Δlon² + Δlat²) mixes two different ground units — the result is a number, not a distance Project to a metric CRS (a local UTM zone) before any distance, area or buffer
Longitude degrees contract by 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

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.

Correct and swapped argument order plotted on a world graticule Two world panels drawn as plain lon-lat graticules with an equator and a prime meridian. On the left, points_from_xy called with longitude then latitude places the Manhattan sensor at minus 73.99, 40.75 over north-eastern North America. On the right, the same two numbers passed in the reverse order build POINT (40.75 -73.99), which lands off the Antarctic coast in the Southern Ocean. Neither call raises an error, so the swap is only visible once the geometry is plotted or its bounds are checked. Both calls succeed — only one puts the sensor in Manhattan points_from_xy(lon, lat) points_from_xy(lat, lon) −180° +180° equator Manhattan 74° W, 40.7° N −180° +180° equator Southern Ocean 40.7° E, 74° S POINT (-73.99 40.75) — the location you meant POINT (40.75 -73.99) — constructed, never validated Catch it early: check total_bounds against the extent you expect before joining or buffering.
The constructor accepts either ordering without complaint, so a swapped pair produces a perfectly valid point in the wrong hemisphere — the failure only surfaces when a join returns nothing.
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

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.