Attribute Join or Spatial Join? Choosing How to Combine Two Layers
On this page
Problem statement
You have Leeds' 33 ward boundaries and a table of their Census 2021 populations. You need them together. There are two ways, and the wrong one is slower, less reliable, and occasionally wrong:
# spatial: match by where things are
joined = gpd.sjoin(wards, population_points, predicate="contains")
# attribute: match by a shared code
joined = wards.merge(population, on="ward_code")
The population table has no geometry — it came from Nomis as codes, names and counts — so here the spatial route is not even available. It usually is, though, when someone has turned a table into points to "put it on the map", and then it is the slower and less exact of the two.
The reverse mistake is just as common. The table has a name column too, and names look like keys:
by_name = wards.merge(population, on="ward_name", how="left")
print(f"{by_name['population'].isna().sum()} of {len(wards)} wards unmatched")
print(population.loc[~population["ward_name"].isin(wards["ward_name"]), "ward_name"].tolist())
1 of 33 wards unmatched
['Harewood (Leeds)']
The boundaries call it "Harewood"; Nomis calls it "Harewood (Leeds)", because it lists every ward in the country and there is more than one Harewood. The code, E05011396, is the same in both, and would have matched every one.
Choosing between them is not a style question. It is a question about where the relationship actually lives — in a shared key, or in the geometry.
Quick answer
| Situation | Use | Why |
|---|---|---|
| both layers share a reliable code | attribute join (merge) |
exact, fast, one-to-one |
| the relationship is positional | spatial join (sjoin) |
there is no key to use |
| a key exists but is unreliable | spatial join, then verify | names drift; geometry does not |
| you need both | spatial join to derive the key, then merge | do the expensive part once |
# a shared key — always prefer this
wards = wards.merge(population, on="ward_code", how="left", validate="one_to_one")
# no key — the relationship is where things are
incidents = gpd.sjoin(incidents, wards[["ward_code", "geometry"]],
how="left", predicate="within")
# derive the key once, reuse it many times
incidents.drop(columns="index_right").to_parquet("incidents_with_ward.parquet")
Use validate= on every merge. It turns a silent cardinality surprise into an exception.
A usable key first, positional joins second
1. Find out whether a usable key exists
def find_shared_keys(left, right):
"""Columns present in both, with how well their values overlap."""
shared = set(left.columns) & set(right.columns) - {"geometry"}
if not shared:
print("no shared column names")
return []
results = []
for col in sorted(shared):
lv = set(left[col].dropna().astype(str).str.strip())
rv = set(right[col].dropna().astype(str).str.strip())
if not lv or not rv:
continue
overlap = len(lv & rv)
results.append({
"column": col,
"left_distinct": len(lv),
"right_distinct": len(rv),
"shared_values": overlap,
"left_matched_%": round(100 * overlap / len(lv), 1),
"left_unique": left[col].is_unique,
"right_unique": right[col].is_unique,
})
df = pd.DataFrame(results).sort_values("left_matched_%", ascending=False)
print(df.to_string(index=False))
return df
keys = find_shared_keys(wards, population)
column left_distinct right_distinct shared_values left_matched_% left_unique right_unique
ward_code 33 33 33 100.0 True True
ward_name 33 33 32 97.0 True True
ward_code matches 100% and is unique on both sides — a perfect key, use it. ward_name misses one, which on 33 rows is 3% of the data gone without an error.
Codes beat names, always. A code is issued by an authority and does not change when someone reformats a spreadsheet or disambiguates a list.
2. Prefer the attribute join when a key exists
merged = wards.merge(population, on="ward_code", how="left", validate="one_to_one",
suffixes=("", "_nomis"))
print(f"{len(wards):,} → {len(merged):,}, {merged['population'].isna().sum():,} unmatched")
33 → 33, 0 unmatched
validate= is the argument that makes a merge safe:
validate |
Asserts |
|---|---|
"one_to_one" |
keys unique on both sides |
"one_to_many" |
left keys unique |
"many_to_one" |
right keys unique |
"many_to_many" |
nothing (the default behaviour) |
A table that has gained a duplicate row — an appended update, a copy-paste — is exactly what it catches:
doubled = pd.concat([population, population.iloc[[0]]])
try:
wards.merge(doubled, on="ward_code", validate="one_to_one")
except pd.errors.MergeError as err:
print(f"MergeError: {err}")
MergeError: Merge keys are not unique in right dataset; not a one-to-one merge
Duplicates in right:
ward_code
E05011384 ...
That error is worth having. Without it, a duplicated key on the right silently multiplies a ward, and the map still renders.
indicator=True shows exactly what matched:
merged = wards.merge(population, on="ward_code", how="outer", indicator=True, suffixes=("", "_nomis"))
print(merged["_merge"].value_counts().to_dict())
{'both': 33, 'left_only': 0, 'right_only': 0}
right_only rows would be population figures for wards that do not exist in your boundaries — a real finding when it happens, usually a boundary revision.
3. Use a spatial join when the relationship is positional
Some relationships have no key by nature. A crime record has coordinates, not a ward code:
located = gpd.sjoin(incidents, wards[["ward_code", "geometry"]], how="left", predicate="within")
print(f"{len(incidents):,} crimes → {located['ward_code'].notna().sum():,} placed in a ward, "
f"{located.index.duplicated().sum()} duplicated")
9,224 crimes → 9,224 placed in a ward, 0 duplicated
The distinction is not about which data you have but about where the relationship is stored. A ward code in a crime record would be someone's earlier spatial join, written down. If it is absent, or you do not trust it, the geometry still knows.
4. Measure the cost difference
The land titles of Leeds make the difference visible. Give each title a ward once, by the point inside it, and then compare a merge on that code against the spatial joins it replaces:
import time
points = parcels.set_geometry(parcels.representative_point())
parcels["ward_code"] = gpd.sjoin(points[["geometry"]], wards[["ward_code", "geometry"]],
how="left", predicate="within")["ward_code"]
ward_attrs = population[["ward_code", "population"]]
def timed(label, fn):
t0 = time.perf_counter()
result = fn()
print(f"{label:<22} {time.perf_counter() - t0:>8.3f} s {len(result):>9,} rows")
return result
timed("merge on ward_code", lambda: parcels.merge(ward_attrs, on="ward_code", how="left"))
timed("sjoin within", lambda: gpd.sjoin(parcels, wards[["geometry"]], how="left", predicate="within"))
timed("sjoin intersects", lambda: gpd.sjoin(parcels, wards[["geometry"]], how="left", predicate="intersects"))
merge on ward_code 0.035 s 293,729 rows
sjoin within 0.789 s 293,729 rows
sjoin intersects 6.220 s 295,784 rows
Note that the row counts differ as well as the times. The merge is exact and one-to-one; within keeps every title but leaves the ones that cross a ward boundary unmatched; intersects duplicates them.
The cost gap comes from what each does. A merge builds a hash table on the key and looks each row up. A spatial join builds an R-tree, queries it per feature, then runs an exact geometry predicate on every candidate — and here the candidates are 293,729 polygons tested against ward boundaries with tens of thousands of vertices. Even with the index, it is fundamentally more work.
5. Use both: derive the key once, then merge
The best pattern for repeated work is a spatial join once, stored, and attribute joins thereafter:
# expensive, run once when the data arrives
located.drop(columns="index_right").to_parquet("incidents_with_ward.parquet")
# cheap, run whenever
stored = gpd.read_parquet("incidents_with_ward.parquet")
by_ward = stored.groupby("ward_code").size().rename("crimes")
summary = wards.merge(by_ward, on="ward_code", how="left", validate="one_to_one")
print(summary.nlargest(3, "crimes")[["ward_name", "crimes"]].to_string(index=False))
ward_name crimes
Little London & Woodhouse 1124
Hunslet & Riverside 589
Gipton & Harehills 572
That is also what a precomputed code in someone else's table is — their spatial join, done once. Which is why you should check it rather than trust it. The ONS publishes exactly such a table: a lookup assigning every LSOA to one ward. Check it against the geometry:
def verify_key_against_geometry(gdf, areas, key, *, sample=2000, seed=0):
"""Does the recorded key agree with where the feature actually is?"""
rng = np.random.default_rng(seed)
idx = rng.choice(len(gdf), min(sample, len(gdf)), replace=False)
probe = gdf.iloc[idx][[key, "geometry"]].copy()
checked = gpd.sjoin(probe, areas[[key, "geometry"]], how="left", predicate="within",
lsuffix="recorded", rsuffix="geom")
checked = checked[~checked.index.duplicated(keep="first")]
recorded, actual = checked[f"{key}_recorded"], checked[f"{key}_geom"]
disagree = actual.notna() & (recorded != actual)
print(f"{len(checked):,} sampled")
print(f" agree {(recorded == actual).sum():,}")
print(f" disagree {disagree.sum():,} ({100 * disagree.mean():.2f}%)")
print(f" no match {actual.isna().sum():,}")
return checked[disagree]
lsoa_points = zones.set_geometry(zones.representative_point())
different = verify_key_against_geometry(lsoa_points, wards, "ward_code")
488 sampled
agree 462
disagree 26 (5.33%)
no match 0
Every disagreement is an LSOA that straddles a ward boundary. The ONS assigns each to one ward on a best-fit rule — where most of its people live — while a single point inside it can fall on the other side. Neither is wrong; they are different rules, and only checking reveals that the lookup is not the same thing as "the ward this area is in".
6. Handle the case where the key is nearly right
Names almost match, and fixing them is often better than falling back to geometry:
def normalise_name(s):
return (s.astype(str).str.lower()
.str.replace(r"\s*\(.*?\)", "", regex=True) # "(Leeds)" disambiguators
.str.replace(r"\s*&\s*", " and ", regex=True)
.str.replace(r"[’']", "", regex=True)
.str.replace(r"[^a-z0-9 ]", " ", regex=True)
.str.replace(r"\s+", " ", regex=True)
.str.strip())
before = wards["ward_name"].isin(population["ward_name"]).sum()
after = normalise_name(wards["ward_name"]).isin(normalise_name(population["ward_name"])).sum()
print(f"exact match: {before}/{len(wards)} → normalised: {after}/{len(wards)}")
exact match: 32/33 → normalised: 33/33
Normalising is cheap and repeatable, and it is worth doing before concluding that a key is unusable — but it is a patch, tuned to the mismatches you have seen. The next table will spell it "Harewood Ward", and a code would not have cared. Where names disagree in ways no rule anticipates, fuzzy matching or a spatial join earns its place.
A joiner for Leeds wards that picks its method
The data used on this page
Leeds' 33 electoral wards (December 2024, full resolution) and its LSOAs with the ONS's LSOA-to-ward lookup, from the ONS Open Geography Portal; each ward's Census 2021 population from Nomis; every street-level crime West Yorkshire Police published for June 2026 in Leeds, from data.police.uk; and HM Land Registry's INSPIRE Index Polygons for Leeds, for the timing in step 4. Land Registry data © Crown copyright and database right 2026, Open Government Licence v3.0.
import geopandas as gpd
import numpy as np
import pandas as pd
import pyogrio
import requests
ONS = "https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services"
wards = gpd.read_file(f"{ONS}/Wards_December_2024_Boundaries_UK_BFC/FeatureServer/0/query"
"?where=LAD24NM%3D%27Leeds%27&outFields=WD24CD,WD24NM&outSR=27700&f=geojson")
wards = (wards.set_crs(27700, allow_override=True).rename(columns={"WD24CD": "ward_code", "WD24NM": "ward_name"})
.sort_values("ward_code").reset_index(drop=True))
# Census 2021 usual residents per ward, as Nomis publishes them: codes, names, counts — no geometry
population = pd.read_csv("https://www.nomisweb.co.uk/api/v01/dataset/NM_2021_1.data.csv?date=latest"
f"&geography={','.join(wards['ward_code'])}&c2021_restype_3=0&measures=20100"
"&select=geography_code,geography_name,obs_value")
population.columns = ["ward_code", "ward_name", "population"]
# LSOAs, with the ward the ONS lookup assigns each one to
features, offset = [], 0
while True:
page = requests.get(f"{ONS}/Lower_layer_Super_Output_Areas_December_2021_Boundaries_EW_BGC_V5/FeatureServer/0/query",
params={"where": "LSOA21NM LIKE 'Leeds %'", "outFields": "LSOA21CD", "outSR": 27700,
"f": "geojson", "resultOffset": offset}, timeout=120).json()
features += page["features"]
if not page.get("properties", {}).get("exceededTransferLimit"):
break
offset += len(page["features"])
zones = gpd.GeoDataFrame.from_features(features, crs=27700)
lookup = pd.DataFrame([f["attributes"] for f in requests.get(
f"{ONS}/LSOA21_WD24_LAD24_EW_LU/FeatureServer/0/query",
params={"where": "LAD24NM='Leeds'", "outFields": "LSOA21CD,WD24CD", "f": "json"}, timeout=120).json()["features"]])
zones = zones.merge(lookup.rename(columns={"WD24CD": "ward_code"}), on="LSOA21CD", validate="one_to_one")
# June 2026 street-level crime, in tiles (the API returns at most 10,000 per request)
west, south, east, north = wards.to_crs(4326).total_bounds
crimes = []
for x0, x1 in zip(np.linspace(west, east, 5)[:-1], np.linspace(west, east, 5)[1:]):
for y0, y1 in zip(np.linspace(south, north, 5)[:-1], np.linspace(south, north, 5)[1:]):
for attempt in range(5): # the API answers bursts with 429
r = requests.post("https://data.police.uk/api/crimes-street/all-crime", timeout=120,
data={"poly": f"{y0},{x0}:{y0},{x1}:{y1},{x1}:{y1},{x0}", "date": "2026-06"})
if r.status_code != 429:
break
time.sleep(2 ** attempt)
r.raise_for_status()
crimes += [{"id": c["id"], "category": c["category"], "lon": float(c["location"]["longitude"]),
"lat": float(c["location"]["latitude"])} for c in r.json()]
crimes = pd.DataFrame(crimes).drop_duplicates("id")
incidents = gpd.GeoDataFrame(crimes[["id", "category"]], crs=4326,
geometry=gpd.points_from_xy(crimes["lon"], crimes["lat"])).to_crs(27700)
incidents = incidents[incidents.within(wards.union_all())].reset_index(drop=True)
# HM Land Registry's download sets a cookie and redirects to itself, so use a session
reply = requests.Session().get("https://use-land-property-data.service.gov.uk/datasets/inspire/download/Leeds.zip",
timeout=600)
reply.raise_for_status()
with open("leeds_inspire.zip", "wb") as f:
f.write(reply.content)
pyogrio.set_gdal_config_options({"GML_DOWNLOAD_SCHEMA": "NO"}) # the file's schema URL is unreachable
parcels = gpd.read_file("zip://leeds_inspire.zip!Land_Registry_Cadastral_Parcels.gml", columns=["INSPIREID"])
print(f"{len(wards)} wards, {len(population)} population rows, {len(zones)} LSOAs, "
f"{len(incidents):,} crimes, {len(parcels):,} titles")
33 wards, 33 population rows, 488 LSOAs, 9,224 crimes, 293,729 titles
Example 1: a joiner that picks the method and reports
import time
def join_layers(left, right, right_cols, *, key=None, predicate="within",
how="left", validate="many_to_one", verbose=True):
"""Attribute-join on `key` if it is usable, otherwise spatial-join."""
t0 = time.perf_counter()
usable_key = None
if key and key in left.columns and key in right.columns:
lv = set(left[key].dropna().astype(str).str.strip())
rv = set(right[key].dropna().astype(str).str.strip())
coverage = len(lv & rv) / max(len(lv), 1)
if verbose:
print(f"key '{key}': {100 * coverage:.1f}% of left values found on the right"
f"{', unique on the right' if right[key].is_unique else ''}")
if coverage >= 0.98 and right[key].is_unique:
usable_key = key
elif verbose:
print(" → not usable as a key; falling back to a spatial join")
elif verbose and key:
print(f"key '{key}': not present on both sides")
if usable_key:
out = left.merge(right[[usable_key, *right_cols]], on=usable_key, how=how, validate=validate)
method = f"attribute join on '{usable_key}'"
else:
joined = gpd.sjoin(left, right[[*right_cols, right.geometry.name]], how=how, predicate=predicate)
dup = int(joined.index.duplicated().sum())
joined = joined[~joined.index.duplicated(keep="first")]
out = joined.drop(columns=[c for c in ("index_right",) if c in joined])
method = f"spatial join, predicate='{predicate}'"
if verbose and dup:
print(f" {dup:,} features matched more than one — kept the first")
if verbose:
unmatched = int(out[right_cols[0]].isna().sum())
print(f"{method}")
print(f" {len(left):,} in → {len(out):,} out ({len(out) - len(left):+,})")
print(f" {unmatched:,} unmatched ({100 * unmatched / max(len(out), 1):.2f}%)")
print(f" {time.perf_counter() - t0:.3f} s")
return out
wards_pop = join_layers(wards, population, ["population"], key="ward_code", validate="one_to_one")
print()
incidents_w = join_layers(incidents, wards, ["ward_code", "ward_name"], key="ward_code", predicate="within")
key 'ward_code': 100.0% of left values found on the right, unique on the right
attribute join on 'ward_code'
33 in → 33 out (+0)
0 unmatched (0.00%)
0.002 s
key 'ward_code': not present on both sides
spatial join, predicate='within'
9,224 in → 9,224 out (+0)
0 unmatched (0.00%)
0.013 s
The 98% coverage threshold is a judgement, and it is deliberately high: a key matching 90% of rows is not a key, it is a partially-correct lookup, and falling back to geometry gives a complete answer instead of a mostly-complete one.
Requiring uniqueness on the right is the other half. A non-unique right key would fan the merge out, which is exactly the surprise validate= exists to prevent — so the function refuses that path rather than raising later.
Printing the timing makes the cost visible, and is the argument for deriving the key once and storing it.
Example 2: deriving and caching a spatial key
from pathlib import Path
import hashlib
def spatial_key(points, areas, key_col, *, cache_dir="cache", predicate="within", force=False):
"""Assign an area key to each point, caching the expensive join."""
cache_dir = Path(cache_dir)
cache_dir.mkdir(exist_ok=True)
fingerprint = hashlib.sha256(
f"{len(points)}|{tuple(round(v, 3) for v in points.total_bounds)}|"
f"{len(areas)}|{tuple(round(v, 3) for v in areas.total_bounds)}|"
f"{key_col}|{predicate}".encode()).hexdigest()[:16]
cached = cache_dir / f"spatial_key_{fingerprint}.parquet"
if cached.exists() and not force:
keys = pd.read_parquet(cached)
print(f" cache hit: {cached.name}")
out = points.copy()
out[key_col] = keys[key_col].reindex(out.index).values
return out
areas_p = areas.to_crs(points.crs) if areas.crs != points.crs else areas
joined = gpd.sjoin(points[[points.geometry.name]], areas_p[[key_col, areas_p.geometry.name]],
how="left", predicate=predicate)
dup = int(joined.index.duplicated().sum())
joined = joined[~joined.index.duplicated(keep="first")].reindex(points.index)
out = points.copy()
out[key_col] = joined[key_col].values
pd.DataFrame({key_col: out[key_col]}).to_parquet(cached)
print(f" computed and cached: {cached.name}")
print(f" {dup:,} boundary duplicates resolved, {int(out[key_col].isna().sum()):,} unmatched")
return out
for _ in range(2): # the second call reads the cache
t0 = time.perf_counter()
keyed = spatial_key(incidents, wards, "ward_code")
print(f" {time.perf_counter() - t0:.3f} s")
computed and cached: spatial_key_b3a7dc53d74f6e4d.parquet
0 boundary duplicates resolved, 0 unmatched
0.014 s
cache hit: spatial_key_b3a7dc53d74f6e4d.parquet
0.002 s
The fingerprint covers row counts, bounds, the key column and the predicate, so a change to either layer invalidates the cache. It is not a content hash — two different layers with identical counts and bounds would collide — but it catches the realistic case of data being updated, and it costs microseconds rather than hashing gigabytes.
Caching only the key column keeps the file tiny: one short string per crime instead of a geometry.
Example 3: reconciling two tables that should match and do not
import re
def reconcile(left, right, key):
"""Why do these two tables not join cleanly on `key`?"""
lv = left[key].dropna().astype(str).str.strip()
rv = right[key].dropna().astype(str).str.strip()
ls, rs = set(lv), set(rv)
print(f"key '{key}'")
print(f" left {len(lv):,} rows, {len(ls):,} distinct, {'unique' if left[key].is_unique else 'NOT unique'}")
print(f" right {len(rv):,} rows, {len(rs):,} distinct, {'unique' if right[key].is_unique else 'NOT unique'}")
print(f" in both {len(ls & rs):,}")
print(f" left only {len(ls - rs):,} {sorted(ls - rs)[:4]}")
print(f" right only {len(rs - ls):,} {sorted(rs - ls)[:4]}")
def norm(s):
s = re.sub(r"\s*\(.*?\)", "", s.lower())
return re.sub(r"\s+", " ", s.replace("&", "and").replace("'", "").replace("-", " ")).strip()
ln = {norm(v): v for v in ls - rs}
rn = {norm(v): v for v in rs - ls}
fixable = set(ln) & set(rn)
if fixable:
print(f"\n {len(fixable):,} would match after normalising:")
for k in sorted(fixable)[:5]:
print(f" {ln[k]!r} ↔ {rn[k]!r}")
return ls, rs
names = reconcile(wards, population, "ward_name")
key 'ward_name'
left 33 rows, 33 distinct, unique
right 33 rows, 33 distinct, unique
in both 32
left only 1 ['Harewood']
right only 1 ['Harewood (Leeds)']
1 would match after normalising:
'Harewood' ↔ 'Harewood (Leeds)'
One mismatch, and normalising fixes it — this time. The more useful reading is the in both count against the distinct counts: a name key that matches 32 of 33 looks nearly perfect and is still wrong for one ward's worth of people, silently, on every run.
Where the relationship is recorded
within is over twenty times slower than the merge, intersects nearly two hundred times, and intersects also changes the row count.The choice between an attribute join and a spatial join is really a question about where the relationship is recorded.
An attribute join reads a relationship someone has already written down. A ward_code on an incident record means somebody decided that incident belongs to that ward — a decision made at some point, by some process, using some boundary vintage. The join looks it up, exactly and cheaply.
A spatial join computes the relationship from geometry. Nobody wrote it down; the join derives it from where the features are. That is more expensive and it is authoritative, because geometry does not have typos, does not drift between versions, and does not depend on someone's spreadsheet formatting.
So the trade is cost against authority, and it usually resolves in favour of the key when a good key exists. A hash-table lookup is O(n); a spatial join builds an index, queries it per feature and runs exact geometry predicates on the candidates. On the Leeds titles the cheapest spatial join was over twenty times slower than the merge and intersects nearly two hundred times, and the gap widens with size and with boundary detail.
But the key is only worth using if it is a good key, and the test is not whether a column exists but whether it matches. A code issued by a statistical agency and present on both sides at 100% coverage is a good key. A name column matching 97% is not a key at all — it is a lookup that silently drops whatever it misses, and what it misses will not be random. Here it was Harewood, because the other publisher lists every ward in the country and had to tell two Harewoods apart. Elsewhere it is the wards with ampersands or apostrophes, which correlate with nothing except punctuation, so the loss looks like noise.
Names are unreliable in specific, predictable ways. Ampersands versus "and", apostrophes present or absent, hyphens versus spaces, capitalisation, trailing whitespace, and genuinely different naming conventions between organisations. Normalising fixes most of it and is worth trying before abandoning the key. What it cannot fix is a genuine disagreement about names, which is where geometry wins outright.
The hybrid pattern is usually best, and it is worth naming: do the spatial join once, store the derived key, and merge thereafter. That is exactly what a supplier's ward_code column is — their spatial join, cached. Recognising that reframes the question: you are not choosing between geometry and a key, you are choosing between your spatial join and someone else's, and the reason to prefer your own is that you know which boundaries and which rule it used.
Which is also why a supplied key deserves verification rather than trust. Checking a sample against the geometry takes seconds and answers whether the two agree. When they do, use the key and enjoy the speed. When they disagree — for 26 of Leeds' 488 LSOAs against the ONS's own lookup — you have learned that the lookup follows a different rule, or a different boundary vintage, from the one you would have used. That is a finding worth having before it shows up as an unexplained discrepancy in a report.
validate=, indicator=True and codes over names
validate=on every merge."one_to_one","many_to_one"— it converts a silent fan-out into an exception.indicator=Trueadds a_mergecolumn showingleft_only,right_onlyorboth.- Codes beat names. A code is issued and stable; a name is formatted by whoever last touched the file.
- Normalise before giving up on a name key — ampersands, apostrophes, case and whitespace explain most mismatches.
- A merge on a non-unique right key multiplies rows. That is what
validatecatches. - Whitespace is invisible and fatal.
.str.strip()on both sides before comparing. - Type mismatches fail silently:
"01"does not match1. Cast both sides. - A spatial join can duplicate rows; a merge on a unique key cannot. See cardinality.
- Cache the derived key, not the whole joined frame — it is a fraction of the size.
- A supplied key is someone else's spatial join. Verify it against a sample of your geometry.
Internal links
- How to join attribute data to a GeoDataFrame in Python — the attribute join in practice
- How to perform a spatial join in Python (GeoPandas) — the spatial join in practice
- Spatial join cardinality explained — why a spatial join changes the row count
- GeoPandas merge returns NaN or no matches — diagnosing a failed attribute join
- How to fuzzy-match place names when joining spatial data — when normalising is not enough
- Nearest-neighbour joins explained — the third kind of join
- How to standardise attribute values against a controlled vocabulary — making a name key usable
- Spatial predicates explained — choosing the spatial relationship
FAQ
Which join should I use?
An attribute join whenever both layers share a reliable code — it is exact, one-to-one, and on Leeds' land titles over twenty times faster than the cheapest spatial join. A spatial join when the relationship exists only in the geometry.
How do I know if a key is good enough?
Check the overlap of distinct values and whether the key is unique on the right. Above about 98% coverage with uniqueness, use it. Below that it is a partial lookup, not a key.
Why does my merge on names lose rows?
Ampersands, apostrophes, hyphens, case and whitespace. Normalise both sides before joining — it typically recovers almost all of the mismatches.
What does validate= do?
It asserts the cardinality you expect and raises if the data disagrees — "one_to_one", "many_to_one" and so on. Without it, a duplicated key silently multiplies rows.
Should I trust a supplied ward code?
Verify it. That column is someone else's spatial join, computed against a boundary vintage you cannot see. Checking a sample against your geometry takes seconds.
Can I use both methods?
That is usually the best pattern: spatial-join once to derive the key, store it, and merge thereafter. You pay the expensive join once instead of on every run.
Why is a spatial join so much slower?
A merge is a hash lookup per row. A spatial join builds an R-tree, queries it per feature and runs an exact geometry predicate on every candidate — fundamentally more work, even with the index.