How to Read a CSV with Coordinates as a GeoDataFrame

On this page

Problem statement

A CSV with coordinate columns is a table, not spatial data: it has numbers, but nothing says which column is x, which coordinate system the numbers are in, or whether they are degrees at all. Turning it into a GeoDataFrame takes three decisions β€” which columns, in which order, and which CRS β€” and only the last one cannot be read off the file.

This page loads two real CSVs: the UK's airports from OurAirports, in longitude and latitude, and Greater Manchester's public-transport stops from NaPTAN, which carries every stop twice β€” in longitude and latitude and in British National Grid metres. That second file makes the CRS choice checkable, and shows what a missing grid file costs.

Quick answer

The shortest working way to read a CSV with coordinates into a GeoDataFrame is to read the CSV with pandas, create point geometry with GeoPandas, and assign the correct CRS.

import pandas as pd
import geopandas as gpd

df = pd.read_csv("points.csv")

gdf = gpd.GeoDataFrame(
    df,
    geometry=gpd.points_from_xy(df["longitude"], df["latitude"]),
    crs="EPSG:4326"
)

print(gdf[["name", "iata_code", "geometry"]].head(3))
print(gdf.crs)
                    name iata_code                   geometry
0       RAF Castle Camps       NaN   POINT (0.37861 52.05139)
1       Ledbury Airfield       NaN   POINT (-2.47416 52.0019)
2  Milfield Glider Field       NaN  POINT (-2.08723 55.59164)
EPSG:4326

Longitude is x and latitude is y, and decimal degrees from a GPS or a web service are WGS 84, EPSG:4326. Easting and northing columns are a projected CRS, which the source has to tell you.

From CSV to spatial

From CSV to spatialCSV (lon,lat)read_csvpoints_from_xyGeoDataFrameset_crs(4326)spatialworkflow.io
Turning a CSV of coordinates into a GeoDataFrame.

Read, build points, set the CRS, verify

Checks to run on a coordinate CSV before building geometry. check the text before you make pointsCoordinate columns are numericto_numeric(errors="coerce")Decimal separator is a dot"59,91" β†’ 59.91Latitude within Β±90, longitude Β±180range checkx and y passed in the right orderpoints_from_xy(lon, lat)Rows with missing coordinates droppeddropna(subset=…)CRS set explicitly after constructioncrs="EPSG:4326"points_from_xy takes x first β€” longitude, then latitude spatialworkflow.io
The argument order in item four is the single most common source of mirrored maps.

Step 1: Read the CSV into a pandas DataFrame

Start by loading the CSV with pandas and checking the coordinate columns.

import pandas as pd

df = pd.read_csv("points.csv")

print(df[["name", "longitude", "latitude"]].head(3))
print(list(df.columns)[:6])
                    name  longitude   latitude
0       RAF Castle Camps   0.378611  52.051389
1       Ledbury Airfield  -2.474160  52.001900
2  Milfield Glider Field  -2.087231  55.591636
['id', 'ident', 'type', 'name', 'latitude', 'longitude']

OurAirports stores latitude before longitude, the order people say them. Check for gaps before building points:

print(df[["longitude", "latitude"]].isna().sum().to_dict())
{'longitude': 0, 'latitude': 0}

This file is clean. A CSV with coordinates stored as text, decimal commas or blanks needs cleaning first β€” cleaning messy CSV coordinates works through a real one.

Step 2: Create point geometry from coordinate columns

Use geopandas.points_from_xy() to build point geometries.

import geopandas as gpd

geometry = gpd.points_from_xy(df["longitude"], df["latitude"])

x first: longitude, then latitude. Passing them the other way round puts the UK's airports in the Indian Ocean without an error (points plot in the ocean).

Step 3: Convert the DataFrame to a GeoDataFrame

Wrap the DataFrame in gpd.GeoDataFrame() and pass the geometry.

gdf = gpd.GeoDataFrame(df, geometry=geometry)

At this stage, you have a geometry column, but the data still needs a CRS to be spatially meaningful.

Step 4: Assign the correct CRS

If the CSV contains geographic coordinates in latitude and longitude, use EPSG:4326.

gdf = gdf.set_crs("EPSG:4326")

If the CSV uses projected coordinates such as easting/northing, assign the projected CRS instead β€” for British data usually British National Grid:

gdf = gdf.set_crs("EPSG:27700")

Do not guess the CRS. If you assign the wrong one, your points will appear in the wrong place and later analysis will be wrong.

Step 5: Verify the result

Check the first rows, geometry type, CRS, and bounds.

print(gdf.geom_type.unique().tolist())
print(gdf.crs)
print([round(float(v), 3) for v in gdf.total_bounds])
['Point']
EPSG:4326
[-8.572, 49.913, 1.723, 60.856]

Longitude βˆ’8.6 to 1.7 and latitude 49.9 to 60.9 is the United Kingdom, where these airports should be. You can also make a quick plot:

gdf.plot()

This is a simple way to confirm that the points were created correctly.

UK airports and projected coordinates from CSV

The data used on this page

Two real CSVs. OurAirports publishes every airport, airfield and heliport it knows as airports.csv (public domain), with latitude_deg and longitude_deg; the setup keeps those in the United Kingdom and renames the columns to latitude and longitude. The Department for Transport's NaPTAN register of public-transport stops, downloaded for Greater Manchester (Open Government Licence), gives every stop both as longitude/latitude and as British National Grid easting/northing β€” the setup writes it three ways for the examples.

from pathlib import Path

import pandas as pd
import requests

airports = pd.read_csv("https://davidmegginson.github.io/ourairports-data/airports.csv")
uk = airports[airports["iso_country"] == "GB"].rename(columns={"latitude_deg": "latitude", "longitude_deg": "longitude"})
uk.to_csv("points.csv", index=False)
uk[uk["type"].isin(["large_airport", "medium_airport"])].to_csv("store_locations.csv", index=False)

if not Path("naptan.csv").exists():
    # NaPTAN area 180 as saved on 9 October 2026 (the register changes weekly); live:
    # naptan.api.dft.gov.uk/v1/access-nodes?dataFormat=csv&atcoAreaCodes=180
    Path("naptan.csv").write_bytes(requests.get("https://data.spatialworkflow.io/naptan-area-180-2026-10-09.csv", timeout=300).content)
stops = pd.read_csv("naptan.csv", low_memory=False)
stops = stops[stops["Status"] == "active"]
stops.rename(columns={"Longitude": "x_coord", "Latitude": "y_coord"}).to_csv("survey_points.csv", index=False)
stops.rename(columns={"Easting": "easting", "Northing": "northing", "Longitude": "longitude",
                      "Latitude": "latitude"}).to_csv("assets_bng.csv", index=False)
stops.rename(columns={"Longitude": "longitude", "Latitude": "latitude"}).to_csv("monitoring_sites.csv", index=False)
print(len(uk), "UK airports;", len(stops), "active stops in Greater Manchester")
1605 UK airports; 14045 active stops in Greater Manchester

Example 1: Read a latitude/longitude CSV into a GeoDataFrame

This is the standard case.

import pandas as pd
import geopandas as gpd

df = pd.read_csv("store_locations.csv")

df["longitude"] = pd.to_numeric(df["longitude"], errors="coerce")
df["latitude"] = pd.to_numeric(df["latitude"], errors="coerce")
df = df.dropna(subset=["longitude", "latitude"])

gdf = gpd.GeoDataFrame(
    df,
    geometry=gpd.points_from_xy(df["longitude"], df["latitude"]),
    crs="EPSG:4326"
)

print(len(gdf), gdf.crs)
88 EPSG:4326

This creates point geometry from longitude and latitude and assigns WGS84.

Example 2: Read a CSV with custom x/y column names

Not every file uses standard names. This example uses x_coord and y_coord.

import pandas as pd
import geopandas as gpd

df = pd.read_csv("survey_points.csv")

df["x_coord"] = pd.to_numeric(df["x_coord"], errors="coerce")
df["y_coord"] = pd.to_numeric(df["y_coord"], errors="coerce")
df = df.dropna(subset=["x_coord", "y_coord"])

gdf = gpd.GeoDataFrame(
    df,
    geometry=gpd.points_from_xy(df["x_coord"], df["y_coord"]),
    crs="EPSG:4326"
)

print(gdf[["CommonName", "geometry"]].head(2))
                CommonName                   geometry
0  Altrincham Rail Station  POINT (-2.34671 53.38801)
1  Altrincham Rail Station   POINT (-2.3467 53.38789)

If these columns are longitude and latitude in decimal degrees, EPSG:4326 is appropriate. If they are projected coordinates, assign the projected CRS instead.

Example 3: Read a CSV with projected coordinates

This is common for engineering, utilities, or local government data. Do not treat projected x/y values as lat/lon.

import pandas as pd
import geopandas as gpd

df = pd.read_csv("assets_bng.csv")

gdf = gpd.GeoDataFrame(
    df,
    geometry=gpd.points_from_xy(df["easting"], df["northing"]),
    crs="EPSG:27700"
)

print(gdf[["CommonName", "geometry"]].head(2))
print(gdf.crs)
                CommonName               geometry
0  Altrincham Rail Station  POINT (377038 387962)
1  Altrincham Rail Station  POINT (377039 387949)
EPSG:27700

This example uses British National Grid, because the stops are in Greater Manchester. Replace the CRS with the correct one for your data.

The NaPTAN file carries both pairs of columns, which makes a useful check on the choice β€” and on what PROJ can do without its grid files:

import numpy as np
from pyproj import Transformer
from pyproj.network import set_network_enabled

for grid in (False, True):
    set_network_enabled(grid)                         # True lets PROJ fetch the OSTN15 grid
    x, y = Transformer.from_crs(4326, 27700, always_xy=True).transform(df["longitude"], df["latitude"])
    gap = np.hypot(x - df["easting"], y - df["northing"])
    print(f"OSTN15 {'on ' if grid else 'off'}: easting/northing and longitude/latitude differ by "
          f"median {np.median(gap):.2f} m, max {gap.max():.2f} m")
set_network_enabled(False)
OSTN15 off: easting/northing and longitude/latitude differ by median 1.71 m, max 2.46 m
OSTN15 on : easting/northing and longitude/latitude differ by median 0.31 m, max 0.91 m

With the grid the two columns agree to within the rounding of the file (whole metres against six decimal places of a degree). Without it, PROJ falls back to a seven-parameter transformation and the same stops land 1.7 m apart β€” harmless on a map, not harmless for an asset register. The transformer is built inside the loop on purpose: GeoPandas reuses the one it built first, so toggling the network between two to_crs calls in one session changes nothing.

How far NaPTAN longitude/latitude lands from its own easting/northing after conversion, with and without the OSTN15 grid. 14,045 Greater Manchester stops, two coordinate pairswithout OSTN15, median1.71 mwith OSTN15, median0.31 m01 m2 mThe same CSV, the same CRS codes; the grid file is the difference spatialworkflow.io
Example 3’s cross-check of the two coordinate columns.

Example 4: Reproject the GeoDataFrame after loading

After loading the CSV, you may need another CRS for web maps or spatial analysis.

import pandas as pd
import geopandas as gpd

df = pd.read_csv("monitoring_sites.csv")

gdf = gpd.GeoDataFrame(
    df,
    geometry=gpd.points_from_xy(df["longitude"], df["latitude"]),
    crs="EPSG:4326"
)

gdf_web = gdf.to_crs("EPSG:3857")
gdf_bng = gdf.to_crs("EPSG:27700")

print(gdf_web.crs, [round(float(v)) for v in gdf_web.total_bounds])
print(gdf_bng.crs, [round(float(v)) for v in gdf_bng.total_bounds])
EPSG:3857 [-302530, 7045776, -219805, 7107830]
EPSG:27700 [352522, 382362, 401783, 419239]

Use .to_crs() only after the original CRS has been assigned correctly.

The CRS is a fact about the source, not the file

What a DataFrame of coordinates is missing, and what a GeoDataFrame adds. two pieces of metadata, and an ordera pandas DataFramea GeoDataFramethe coordinatestwo number columnsβœ“Point geometrieswhat they meanβœ•nothing records itβœ“a CRS, e.g. EPSG:4326the orderwhichever you typedβœ“x = lon, y = latpoints_from_xy takes x first β€” reversing it is the single most common CSV mistake spatialworkflow.io
Making geometry and assigning a CRS are two separate steps because they answer two separate questions.

A CSV carries no metadata, so the coordinate system is the one piece of a GeoDataFrame you have to supply from outside the file. The numbers narrow it down β€” values within Β±180 and Β±90 are probably degrees, six-figure positives in Britain probably the national grid β€” but they cannot decide it, and a wrong guess gives no error, only points in the wrong place.

NaPTAN shows the better way when it is available. Every stop has both pairs of columns, so building points from each with its own CRS and comparing them tests the choice: with PROJ's transformation grid the two agree to the rounding of the file, and without it they disagree by 1.7 m, because PROJ then falls back to a seven-parameter approximation of the shift between the two datums. A second source of coordinates for even a few rows is the cheapest CRS check there is.

Projected columns, delimiters and encodings

  • Easting and northing are not degrees. Assign the projected CRS the source documents; EPSG:4326 on metres gives no error and puts the points thousands of kilometres away.
  • Semicolons and Latin-1 are common in CSVs from European locales and older Excel exports: pd.read_csv(path, sep=";", encoding="latin1").
  • Text, blank and decimal-comma coordinates need cleaning before points_from_xy; see cleaning messy CSV coordinates.

If you need a refresher on CRS concepts, see What Is a Coordinate Reference System (CRS) in GeoPandas?.

For the next step after loading data, see How to Reproject Spatial Data in Python (GeoPandas).

To save your points for use in other tools, see How to Export GeoJSON in Python with GeoPandas.

If your points are not showing where you expect, check Why Are My Points Showing in the Wrong Location in GeoPandas?.

FAQ

How do I convert a CSV with latitude and longitude to a GeoDataFrame in Python?

Read the CSV with pandas.read_csv(), create point geometry with gpd.points_from_xy(), and wrap the result in gpd.GeoDataFrame() with the correct CRS.

What CRS should I use for a CSV with lat/lon columns?

In most cases, use EPSG:4326 if the coordinates are longitude and latitude in decimal degrees in WGS84.

Why are my CSV points appearing in the wrong place on the map?

The most common causes are:

  • latitude and longitude columns were swapped
  • the wrong CRS was assigned
  • projected coordinates were incorrectly treated as WGS84
  • coordinate values were malformed or read as text

How do I read a CSV with coordinates directly using gpd.read_file?

GeoPandas can read a CSV through GDAL's CSV driver, but you must supply the coordinate columns and CRS:

direct = gpd.read_file("points.csv", x_possible_names="longitude", y_possible_names="latitude")
print(direct.crs, direct.geometry.iloc[0])
direct = direct.set_crs("EPSG:4326")
None POINT (0.378611 52.051389)

It reads the geometry and leaves the CRS as None, so the set_crs is not optional. The pandas plus points_from_xy approach is usually clearer and gives more control over cleaning.

What happens to rows with empty coordinate cells when building geometry?

points_from_xy produces a point with NaN coordinates rather than raising an error β€” and it is not an empty point, so the usual checks miss it:

odd = gpd.GeoSeries(gpd.points_from_xy([float("nan")], [float("nan")]))
print(odd.iloc[0], "| empty:", odd.is_empty.iloc[0], "| missing:", odd.isna().iloc[0], "| valid:", odd.is_valid.iloc[0])
POINT (NaN NaN) | empty: False | missing: False | valid: False

Only is_valid notices. Drop the rows first with df.dropna(subset=["longitude", "latitude"]) so spatial operations and plots behave.

My CSV has a single column like "40.7,-74.0" β€” how do I split it into coordinates?

Split the combined column before creating geometry, for example df[["lat", "lon"]] = df["coords"].str.split(",", expand=True), then convert both to numeric with pd.to_numeric. After that, pass longitude as x and latitude as y to points_from_xy.

How do I save the resulting GeoDataFrame back out, for example to a GeoPackage?

Once you have a GeoDataFrame with geometry and a CRS, write it with to_file, for example gdf.to_file("points.gpkg", driver="GPKG"). For GeoJSON instead, reproject to EPSG:4326 first and use to_file(..., driver="GeoJSON").