How to Join Attribute Data to a GeoDataFrame in Python
On this page
Problem statement
Joining a table of statistics to boundaries β here ONS mid-2025 population estimates onto the UK's 361 local authority districts β is a key match, not a spatial one. You have:
- a shapefile or GeoJSON of district boundaries loaded into a GeoDataFrame
- a CSV file with population, land use, or survey results
- a shared ID field such as
district_id
In GeoPandas, this is an attribute join, not a spatial join. You are matching rows by a common column, not by location or geometry overlap.
This page shows the typical GeoPandas workflow for an attribute join: reading spatial data, loading a CSV, checking the join key, merging the tables, verifying the result, and exporting the joined layer.
Quick answer
Use GeoDataFrame.merge() on a shared key column.
The safest pattern is to keep the GeoDataFrame on the left side of the join:
joined = gdf.merge(df, on="district_id", how="left")
This preserves the geometry column and returns a GeoDataFrame. In most GIS workflows:
- use
how="left"to keep all spatial features - use
how="inner"to keep only matched features
Diagnosing a join that returns null columns
What you get β from Load the spatial dataset into a GeoDataFrame
LAD24CD ... geometry
0 E06000001 ... MULTIPOLYGON (((448964.105 536757.184, 448986....
1 E06000002 ... MULTIPOLYGON (((451894.299 521145.303, 453997....
2 E06000003 ... MULTIPOLYGON (((478232.947 518788.802, 477689....
[3 rows x 3 columns]
['LAD24CD', 'LAD24NM', 'geometry']
geometry 361 GeoDataFrame
Population estimates onto UK districts, key by key
The walk-through joins the ONS mid-2025 population estimates to the 361 local authority districts of the UK (the December 2024 boundaries), both downloaded in the setup block under Code examples.
Load the spatial dataset into a GeoDataFrame
Read your shapefile, GeoPackage or GeoJSON with GeoPandas and inspect the key field.
import geopandas as gpd
gdf = gpd.read_file("data/districts.gpkg")
print(gdf.head(3))
print(list(gdf.columns))
print(gdf.geometry.name, len(gdf), type(gdf).__name__)
LAD24CD ... geometry
0 E06000001 ... MULTIPOLYGON (((448964.105 536757.184, 448986....
1 E06000002 ... MULTIPOLYGON (((451894.299 521145.303, 453997....
2 E06000003 ... MULTIPOLYGON (((478232.947 518788.802, 477689....
[3 rows x 3 columns]
['LAD24CD', 'LAD24NM', 'geometry']
geometry 361 GeoDataFrame
Check that:
- the expected join column exists β here the ONS code
LAD24CD - the geometry column is present
- the dataset loaded as a
GeoDataFrame
Load the attribute table
Read the non-spatial table with pandas.
import pandas as pd
df = pd.read_csv("data/population_2025.csv")
print(df.head(3))
print(list(df.columns), len(df))
DATE_NAME GEOGRAPHY_CODE GEOGRAPHY_NAME GENDER_NAME OBS_VALUE
0 2025 E06000001 Hartlepool Total 99656
1 2025 E06000002 Middlesbrough Total 152795
2 2025 E06000003 Redcar and Cleveland Total 139541
['DATE_NAME', 'GEOGRAPHY_CODE', 'GEOGRAPHY_NAME', 'GENDER_NAME', 'OBS_VALUE'] 361
For a typical GIS attribute join, keep only the columns you need, under names that say what they are:
df = (df[["GEOGRAPHY_CODE", "OBS_VALUE"]]
.rename(columns={"OBS_VALUE": "population_2025"}))
This helps avoid unnecessary duplicate fields in the output β the district name is already in the boundaries.
Check that the join key matches in both tables
Before joining, verify that the key fields have compatible data types and values in both tables.
print(gdf["LAD24CD"].dtype, df["GEOGRAPHY_CODE"].dtype)
print("codes in the boundaries but not the table:", len(set(gdf["LAD24CD"]) - set(df["GEOGRAPHY_CODE"])))
print("codes in the table but not the boundaries:", len(set(df["GEOGRAPHY_CODE"]) - set(gdf["LAD24CD"])))
str str
codes in the boundaries but not the table: 0
codes in the table but not the boundaries: 0
Here the two sides agree exactly. When they do not, the usual culprits are one table storing IDs as integers and the other as strings (Example 2), stray whitespace or case, or β as in Example 1 β a table published for a different vintage of the boundaries. Cleaning whitespace costs nothing:
gdf["LAD24CD"] = gdf["LAD24CD"].astype(str).str.strip()
df["GEOGRAPHY_CODE"] = df["GEOGRAPHY_CODE"].astype(str).str.strip()
Also check for duplicates in the attribute table:
duplicate_keys = df[df["GEOGRAPHY_CODE"].duplicated(keep=False)]
print(len(duplicate_keys), "rows share a code")
0 rows share a code
If the CSV should contain one row per district, duplicates need to be fixed before merging β Example 3 shows what they do when they are not.
Join the attribute table to the GeoDataFrame
Use a column-based merge. A left join is usually the right default for GIS because it keeps all spatial features. The key has a different name on each side, so name both:
joined = gdf.merge(
df,
left_on="LAD24CD",
right_on="GEOGRAPHY_CODE",
how="left",
validate="one_to_one",
)
validate="one_to_one" makes pandas raise MergeError if either side has a repeated key, instead of quietly multiplying rows. If you only want features with matching attribute records, use an inner join:
matched_only = gdf.merge(df, left_on="LAD24CD", right_on="GEOGRAPHY_CODE", how="inner")
print(len(matched_only))
361
When the key has the same name in both tables, on="LAD24CD" replaces the pair and the key appears once in the output; with left_on/right_on both columns are kept.
Verify the result
Check the output after the merge.
print(type(joined).__name__, len(joined), joined.crs)
print(list(joined.columns))
print(joined[["LAD24NM", "population_2025"]].sort_values("population_2025").head(3).to_string(index=False))
GeoDataFrame 361 EPSG:27700
['LAD24CD', 'LAD24NM', 'geometry', 'GEOGRAPHY_CODE', 'population_2025']
LAD24NM population_2025
Isles of Scilly 2299
City of London 15631
Orkney Islands 21920
You want to confirm:
- the expected attribute columns were added
- the geometry column still exists
- the row count makes sense
You can also check how many spatial features did not get a match:
missing = joined["population_2025"].isna().sum()
print(f"Rows with no matching population record: {missing}")
Rows with no matching population record: 0
None, which is worth confirming rather than assuming: a null after a left join means either no row matched or the matching row was empty, and only indicator=True tells them apart:
check = gdf.merge(df, left_on="LAD24CD", right_on="GEOGRAPHY_CODE", how="left", indicator=True)
blank = check[(check["_merge"] == "both") & check["population_2025"].isna()]
print((check["_merge"] == "left_only").sum(), "unmatched;", len(blank), "matched an empty value")
print(blank["LAD24NM"].head(4).tolist(), "... codes start with", sorted(blank["LAD24CD"].str[0].unique()))
0 unmatched; 0 matched an empty value
[] ... codes start with []
Both are zero on today's table. When this page was first run, on 30 September 2026, before Northern Ireland's mid-2025 figures were in NOMIS, the same check found 11 rows that matched an empty value β all of Northern Ireland's districts, present in the table with no value β although the key check had found every code on both sides. No amount of key cleaning fixes a gap like that; it is in the source, and only this check sees it.
Save the joined output
Export the result to a GIS format.
from pathlib import Path
Path("output").mkdir(exist_ok=True)
joined = joined.drop(columns="GEOGRAPHY_CODE")
joined.to_file("output/district_population.gpkg", layer="districts", driver="GPKG")
joined.to_file("output/district_population.geojson", driver="GeoJSON")
joined.to_file("output/district_population.shp")
for path in ["output/district_population.gpkg", "output/district_population.geojson",
"output/district_population.shp"]:
back = gpd.read_file(path)
print(f"{path.split('.')[-1]:8} {list(back.columns)} {back.crs} {Path(path).stat().st_size / 1e6:.1f} MB")
gpkg ['LAD24CD', 'LAD24NM', 'population_2025', 'geometry'] EPSG:27700 0.7 MB
geojson ['LAD24CD', 'LAD24NM', 'population_2025', 'geometry'] EPSG:27700 1.3 MB
shp ['LAD24CD', 'LAD24NM', 'population', 'geometry'] EPSG:27700 0.5 MB
The shapefile cut population_2025 to ten characters, so every script that reads it back by name now fails. GeoJSON kept the name but, by default, not the coordinates a GeoJSON reader expects: GDAL wrote the British National Grid metres it was given, and only GDAL-aware readers see the CRS. Reproject with to_crs(4326) first if the file is going to a web map. Prefer GeoPackage when you can.
Older boundaries, mismatched key types and multiplying tables
The data used on this page
ONS boundaries for the UK's local authority districts (December 2024, generalised), and three tables from the ONS population estimates on NOMIS: mid-2025 by district, mid-2020 as published for the April 2021 districts, and mid-2025 split by sex. Example 2 uses the US Census Bureau's Colorado county boundaries and its Vintage 2024 county population estimates.
import io
import os
import zipfile
import geopandas as gpd
import pandas as pd
import requests
os.makedirs("data", exist_ok=True)
if not os.path.exists("data/districts.gpkg"):
reply = requests.get("https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/"
"Local_Authority_Districts_December_2024_Boundaries_UK_BUC/FeatureServer/0/query",
params={"where": "1=1", "outFields": "LAD24CD,LAD24NM", "outSR": 27700,
"f": "geojson"}, timeout=120)
reply.raise_for_status()
gpd.read_file(io.BytesIO(reply.content)).to_file("data/districts.gpkg")
NOMIS = ("https://www.nomisweb.co.uk/api/v01/dataset/NM_2002_1.data.csv?c_age=200&measures=20100"
"&select=date_name,geography_code,geography_name,gender_name,obs_value")
tables = {"population_2025.csv": "&geography=TYPE424&date=2025&gender=0",
"population_2020_lad21.csv": "&geography=TYPE432&date=2020&gender=0",
"population_by_sex_2025.csv": "&geography=TYPE424&date=2025&gender=0,1,2"}
for name, query in tables.items():
if not os.path.exists("data/" + name):
reply = requests.get(NOMIS + query, timeout=120)
reply.raise_for_status()
open("data/" + name, "wb").write(reply.content)
if not os.path.exists("data/colorado_counties.shp"):
reply = requests.get("https://www2.census.gov/geo/tiger/GENZ2024/shp/cb_2024_us_county_500k.zip", timeout=300)
reply.raise_for_status()
zipfile.ZipFile(io.BytesIO(reply.content)).extractall("census")
us = gpd.read_file("census/cb_2024_us_county_500k.shp")
us[us["STUSPS"] == "CO"][["GEOID", "NAME", "geometry"]].to_file("data/colorado_counties.shp")
if not os.path.exists("data/co-est2024-alldata.csv"):
reply = requests.get("https://www2.census.gov/programs-surveys/popest/datasets/2020-2024/counties/totals/"
"co-est2024-alldata.csv", timeout=300)
reply.raise_for_status()
open("data/co-est2024-alldata.csv", "wb").write(reply.content)
print(f"{'districts.gpkg':28} {len(gpd.read_file('data/districts.gpkg')):>5} features")
for name in [*tables, "co-est2024-alldata.csv"]:
print(f"{name:28} {len(pd.read_csv('data/' + name, encoding='latin-1')):>5} rows")
print(f"{'colorado_counties.shp':28} {len(gpd.read_file('data/colorado_counties.shp')):>5} features")
districts.gpkg 361 features
population_2025.csv 361 rows
population_2020_lad21.csv 374 rows
population_by_sex_2025.csv 1083 rows
co-est2024-alldata.csv 3195 rows
colorado_counties.shp 64 features
Example 1: a table published for older boundaries
Population tables are often published on the boundaries of their year. Mid-2020 estimates come on the April 2021 districts β before Cumbria, North Yorkshire and Somerset replaced 17 districts with four unitary authorities in April 2023. indicator=True with an outer join shows both sides of the mismatch at once:
districts = gpd.read_file("data/districts.gpkg")
pop2020 = (pd.read_csv("data/population_2020_lad21.csv")
.rename(columns={"OBS_VALUE": "population_2020"}))
check = districts.merge(pop2020, left_on="LAD24CD", right_on="GEOGRAPHY_CODE",
how="outer", indicator=True)
print(check["_merge"].value_counts().to_string())
print("districts with no 2020 row:", check.loc[check["_merge"] == "left_only", "LAD24NM"].tolist())
print("2020 rows with no district:", check.loc[check["_merge"] == "right_only", "GEOGRAPHY_NAME"].tolist())
result = districts.merge(pop2020[["GEOGRAPHY_CODE", "population_2020"]],
left_on="LAD24CD", right_on="GEOGRAPHY_CODE", how="left")
print(f"left join: {len(result)} rows, {result['population_2020'].isna().sum()} without a population")
_merge
both 357
right_only 17
left_only 4
districts with no 2020 row: ['Cumberland', 'Westmorland and Furness', 'North Yorkshire', 'Somerset']
2020 rows with no district: ['Allerdale', 'Barrow-in-Furness', 'Carlisle', 'Copeland', 'Eden', 'South Lakeland', 'Craven', 'Hambleton', 'Harrogate', 'Richmondshire', 'Ryedale', 'Scarborough', 'Selby', 'Mendip', 'Sedgemoor', 'South Somerset', 'Somerset West and Taunton']
left join: 361 rows, 4 without a population
A left join reports only half of this: four districts with no value. The 17 table rows that matched nothing disappear without a trace, which is why the outer join with indicator=True is worth running once before the real merge. (NOMIS now carries those 17 old codes with no value, because the revised estimates are published on the new districts; in a table saved at the time, they would hold the people who belong in the four empty districts.) The fix here is not a type cast but a lookup: sum the old districts into the new ones with the ONS code-change table, or fetch the table on current boundaries, as the walk-through above does.
Example 2: fix mismatched join key types before merging
The Census Bureau's county population file stores the state and county FIPS codes as separate numbers; the boundaries carry a five-character string GEOID. This is a common reason a GeoPandas merge appears to fail:
counties = gpd.read_file("data/colorado_counties.shp")
estimates = pd.read_csv("data/co-est2024-alldata.csv", encoding="latin-1")
estimates = estimates[(estimates["STNAME"] == "Colorado") & (estimates["SUMLEV"] == 50)]
print(counties["GEOID"].dtype, "|", estimates[["STATE", "COUNTY"]].dtypes.to_dict())
print(counties["GEOID"].head(3).tolist(), estimates[["STATE", "COUNTY"]].head(3).values.tolist())
attempts = {
"STATE + COUNTY as str": estimates["STATE"].astype(str) + estimates["COUNTY"].astype(str),
"zero-padded": estimates["STATE"].astype(str).str.zfill(2) + estimates["COUNTY"].astype(str).str.zfill(3),
}
for label, key in attempts.items():
joined = counties.merge(estimates.assign(GEOID=key)[["GEOID", "POPESTIMATE2024"]], on="GEOID", how="left")
print(f"{label:22} matched {joined['POPESTIMATE2024'].notna().sum()} of {len(counties)}")
str | {'STATE': dtype('int64'), 'COUNTY': dtype('int64')}
['08067', '08059', '08065'] [[8, 1], [8, 3], [8, 5]]
STATE + COUNTY as str matched 0 of 64
zero-padded matched 64 of 64
astype(str) makes the types agree and the values still differ β 8 and 1 become "81", not "08001". Only the zero-padded key matches all 64 counties.
Example 3: keep all geometries, and catch a table that multiplies them
A left join keeps every feature, but it does not stop a feature being repeated. The by-sex table has three rows per district β total, male and female:
by_sex = pd.read_csv("data/population_by_sex_2025.csv")
print(by_sex["GENDER_NAME"].value_counts().to_dict())
joined = districts.merge(by_sex, left_on="LAD24CD", right_on="GEOGRAPHY_CODE", how="left")
print(f"{len(districts)} districts -> {len(joined)} rows; "
f"total population now {joined['OBS_VALUE'].sum():,.0f}")
try:
districts.merge(by_sex, left_on="LAD24CD", right_on="GEOGRAPHY_CODE", how="left",
validate="one_to_one")
except pd.errors.MergeError as exc:
print("MergeError:", str(exc).splitlines()[0])
wide = by_sex.pivot(index="GEOGRAPHY_CODE", columns="GENDER_NAME", values="OBS_VALUE").reset_index()
joined = districts.merge(wide, left_on="LAD24CD", right_on="GEOGRAPHY_CODE", how="left",
validate="one_to_one")
no_value = joined[joined["Total"].isna()]
print(f"pivoted: {len(joined)} rows, columns {list(wide.columns[1:])}, total {joined['Total'].sum():,.0f}, "
f"without a value: {len(no_value)}")
{'Total': 361, 'Male': 361, 'Female': 361}
361 districts -> 1083 rows; total population now 138,967,878
MergeError: Merge keys are not unique in right dataset; not a one-to-one merge
pivoted: 361 rows, columns ['Female', 'Male', 'Total'], total 69,483,939, without a value: 0
Nothing about the tripled result looks wrong at a glance β every row has a geometry and a number β but a map of it draws each district three times and any sum counts everyone twice. validate turns that into an error; pivoting the long table to one row per district is the fix.
Example 4: export the joined GeoDataFrame
After you join the DataFrame to the GeoDataFrame by key, save it to a format that preserves field names and geometry cleanly β here reprojected for a web map as well:
joined = joined.drop(columns="GEOGRAPHY_CODE")
joined.to_file("output/districts_by_sex.gpkg", layer="districts", driver="GPKG")
joined.to_crs(4326).to_file("output/districts_by_sex.geojson", driver="GeoJSON")
print(gpd.read_file("output/districts_by_sex.geojson").total_bounds.round(2))
[-8.65 49.88 1.76 60.86]
A shared column, not a shared location
An attribute join matches records by a shared column such as district_id, parcel_id, or code. This is different from a spatial join, which matches features by location, intersection, containment, or nearest distance.
The left table matters. If the left object is a GeoDataFrame, the result keeps the geometry and remains spatial:
gdf = gpd.read_file("data/districts.gpkg")
df = pd.read_csv("data/population_2025.csv")
geo_left = gdf.merge(df, left_on="LAD24CD", right_on="GEOGRAPHY_CODE", how="left")
table_left = df.merge(gdf, left_on="GEOGRAPHY_CODE", right_on="LAD24CD", how="left")
for name, result in [("gdf.merge(df)", geo_left), ("df.merge(gdf)", table_left)]:
print(f"{name:14} {type(result).__name__:12} has .crs: {hasattr(result, 'crs')}")
gdf.merge(df) GeoDataFrame has .crs: True
df.merge(gdf) DataFrame has .crs: False
This is the standard way to merge a pandas DataFrame with a GeoDataFrame while preserving geometry.
In practical GIS terms:
- left join: keep all spatial features, add attributes where matches exist
- inner join: keep only features that have a matching record in the attribute table
A duplicate key in either table can create multiple output rows for one or more features. That may be valid in a one-to-many workflow, but it often indicates bad source data.
Duplicate keys, nulls and shapefile field limits
Join key contains duplicates
If the non-spatial table has repeated IDs, one feature may be duplicated in the output.
by_sex = pd.read_csv("data/population_by_sex_2025.csv")
print(by_sex["GEOGRAPHY_CODE"].value_counts().loc[lambda s: s > 1].head(3))
GEOGRAPHY_CODE
E06000001 3
E06000002 3
E06000003 3
Name: count, dtype: int64
Duplicates in either table can increase the number of output rows after the merge.
If you expect one record per feature, remove or fix duplicates before the merge.
Join key has different data types
An integer key and a string key do not quietly fail to match β pandas refuses the merge:
counties = gpd.read_file("data/colorado_counties.shp")
estimates = pd.read_csv("data/co-est2024-alldata.csv", encoding="latin-1")
try:
counties.merge(estimates[["COUNTY", "POPESTIMATE2024"]], left_on="GEOID", right_on="COUNTY")
except ValueError as exc:
print("ValueError:", exc)
ValueError: You are trying to merge on str and int64 columns for key 'GEOID'. If you wish to proceed you should use pd.concat
Converting both to strings removes the error but not the problem, as Example 2 shows: build the key so the values match, not just the types.
Missing matches create null values
In a left join, unmatched features get NaN in the new columns. That is expected behavior. Review those records after the merge.
CRS issues
CRS does not affect a pure attribute join because the match is based on columns, not spatial relationships. Still, check CRS before exporting or using the data in later spatial operations.
print(geo_left.crs)
EPSG:27700
If you need to change coordinate systems after the join, see How to Reproject Spatial Data in Python (GeoPandas).
Invalid geometries
Invalid geometries usually do not break an attribute join, but they can cause problems in later GIS processing. If the joined layer will be used for overlays, buffers, or spatial joins, validate geometry after loading.
invalid = ~gdf.is_valid
print(f"Invalid geometries: {invalid.sum()} of {len(gdf)}")
Invalid geometries: 0 of 361
Shapefile field name and type limitations
If your workflow ends with shapefile export, watch for truncated field names: population_2025 came back as population in the export above. Shapefile is limited compared to GeoPackage. Use GeoPackage when possible.
Internal links
If you need the conceptual difference between column joins and location-based joins, see Attribute join vs spatial join in GeoPandas.
For the data loading step, see How to Read a Shapefile in Python with GeoPandas.
If your task depends on geometry relationships instead of shared IDs, see How to spatially join two GeoDataFrames in Python.
If your merge runs but produces null values or no matches, see Why a GeoPandas merge returns missing values or no matches.
For exporting the result, see How to Export GeoJSON in Python with GeoPandas.
FAQ
How do I join a CSV to a GeoDataFrame in GeoPandas?
Read the spatial layer with GeoPandas, read the CSV with pandas, then merge on a shared key:
import geopandas as gpd
import pandas as pd
gdf = gpd.read_file("data/districts.gpkg")
df = pd.read_csv("data/population_2025.csv")
joined = gdf.merge(df, left_on="LAD24CD", right_on="GEOGRAPHY_CODE", how="left")
Why does my join return null values for the new columns?
Usually because:
- the key values do not match exactly
- one side is string and the other is integer
- there is whitespace or inconsistent formatting
- some spatial features have no matching record in the table
How do I keep geometry after merging a pandas DataFrame with a GeoDataFrame?
Keep the GeoDataFrame on the left side:
joined = gdf.merge(df, on="id", how="left")
This preserves geometry and returns a GeoDataFrame.
How do I join two GeoDataFrames on an index instead of a column?
Use join() for an index-based match, or reset the index and use merge() on the resulting column. merge() with on=, left_on=/right_on= is the more explicit and common choice for attribute joins.
Why do I get _x and _y suffixes on columns after merging?
Both tables contain a column with the same name. Either drop the duplicate before merging, or control the labels with the suffixes parameter, for example gdf.merge(df, on="id", suffixes=("", "_census")).
How do I join one attribute row to many spatial features (one-to-many)?
Keep the GeoDataFrame with the repeated key on the left and the single-row table on the right: gdf.merge(lookup, on="region_code", how="left"). Each matching feature receives a copy of the lookup row, which is expected behavior, not duplication of source data.
Can I merge on more than one key column?
Yes. Pass a list to on, for example gdf.merge(df, on=["state", "county"], how="left"). Both tables must contain all listed columns with matching values and compatible types.