Rooftop, Interpolated or Centroid: Geocoding Precision Levels
On this page
Problem statement
Two rows in a geocoded table look identical:
customer lat lon
A 51.503488 -0.127697
B 51.507446 -0.127765
One is the front door of a specific building. The other is the centre of Greater London, standing in for an address the geocoder could not find. Both are valid WGS84 coordinates with six decimal places. Nothing in the numbers distinguishes them.
The difference is the precision level: what kind of feature the geocoder actually matched. It is reported in every serious geocoding response, discarded by most pipelines, and it is the single most important field for anything downstream that measures distance.
Quick answer
There are six levels that matter, and they differ by orders of magnitude. Measured on one real address with Nominatim (Example 5), taking the matched feature's bounding-box diagonal as the size of the answer:
level example match feature span error vs the door
rooftop the building itself 55 m 0 m
street the road it stands on 98 m 50 m
postcode the postal unit 1,978 m 0 m
locality "London" 73,966 m 440 m
region a state or county varies varies
country "United Kingdom" 1,618,789 m 413,682 m
Keep the level with the coordinate, and set a floor per analysis:
PRECISION_RANK = {"rooftop": 0, "building": 0, "house": 0, "office": 0,
"street": 1, "road": 1,
"postcode": 2, "postal": 2,
"locality": 3, "suburb": 3, "city": 3, "town": 3,
"region": 4, "state": 4, "county": 4,
"country": 5}
FLOOR = {
"delivery routing": "rooftop",
"walking catchment": "street",
"site selection": "street",
"postcode-level report": "postcode",
"national choropleth": "locality",
}
What each level physically is, a shared ladder, a floor
1. Learn what each level physically is
Rooftop / address point. A coordinate stored for that specific address, usually from a national address register or an OSM address node. Error: metres. It is a point on or near the building, not necessarily the entrance.
Parcel or building centroid. The middle of the property. Error: tens of metres, more for large sites. Note that a centroid can fall outside a U-shaped building or on the wrong side of a courtyard.
Street interpolation. The geocoder knows the road runs from number 1 to number 99 and places number 50 halfway along. Error: tens of metres in a dense terrace, hundreds of metres on a rural road where numbering is irregular, and systematically biased where houses are not evenly spaced.
Postcode / ZIP centroid. A representative point for a postal unit. In the UK a postcode covers roughly fifteen addresses and the measured span was 1,978 m; a US ZIP covers thousands of addresses and is not officially a polygon at all.
Locality centroid. The middle of a town or city. The measured "London" match had a bounding box spanning 74.0 km. It was still only 440 m from the true address, because that address happens to be near the centre โ which is exactly how this level fools people.
Region and country centroid. A single point standing for an administrative area. The measured country match was 414 km from the address.
2. Map the provider's vocabulary to your own ladder
Every provider names these differently: addresstype in Nominatim, location_type with values like ROOFTOP and GEOMETRIC_CENTER elsewhere, match_level, accuracy, or a set of match codes. Normalise them into one internal vocabulary at the adapter, exactly as you would normalise units.
Do this even with a single provider. It gives you one place to fix the mapping when the provider adds a level, and it makes multi-provider tables comparable.
3. Set the floor from the analysis, not from the data
The right floor is a property of the question:
- Point-in-polygon into small areas (census output areas, delivery zones): rooftop or street. A postcode centroid crosses small-area boundaries routinely.
- Distance to nearest facility: street, if the facilities are kilometres apart; rooftop if they are hundreds of metres apart.
- Isochrones and routing: street at minimum โ the router needs to snap to the network anyway.
- National or regional summaries: postcode or locality is genuinely adequate, and pretending otherwise wastes money.
Write the floor down in the pipeline config next to the analysis it belongs to. It is a parameter of the analysis, not a global setting.
4. Decide the fallback policy explicitly
When a row fails to meet the floor, there are exactly three honest options:
- Drop it, and report how many were dropped and why.
- Keep it at the coarser level, labelled, and exclude it from the analyses that cannot tolerate it.
- Send it for review.
The dishonest fourth option is to keep it unlabelled, which is what happens by default when the precision column is not stored.
5. Make the level visible on the map
A map of geocoded points should show precision, not hide it. Symbolise by level โ solid for rooftop, hollow for street, cross-hatched for postcode โ or draw the uncertainty as a circle whose radius is the typical error for that level.
The first time a stakeholder sees the 2 km circles around a third of the points, the conversation about data quality happens on its own.
A precision audit of food businesses
The data used on this page
The addresses are real: 300 food businesses drawn at random from Herefordshire's entries in the Food Standards Agency's food hygiene ratings API, a mostly rural county where every precision level turns up. Each is geocoded with Nominatim the way a pipeline with a fallback would: the full address first, then without its first line (usually the business name), then the postcode alone โ keeping the first match and recording which attempt produced it. The FSA also publishes a coordinate for each business, supplied by the local authority, which Example 4 uses as the reference. Nominatim's usage policy allows one request a second, so the first run takes about ten minutes; results are cached in geocoded.json.
import json
import os
import time
import pandas as pd
import requests
if not os.path.exists("herefordshire.json"):
reply = requests.get("https://api.ratings.food.gov.uk/Establishments",
params={"localAuthorityId": 383, "pageSize": 5000}, # Herefordshire
headers={"x-api-version": "2"}, timeout=300)
reply.raise_for_status()
json.dump(reply.json()["establishments"], open("herefordshire.json", "w"))
est = pd.DataFrame(json.load(open("herefordshire.json")))
sample = est[est.PostCode.fillna("").str.len() > 4].sample(300, random_state=1)
session = requests.Session()
session.headers["User-Agent"] = "spatialworkflow.io guide (geocoding precision example)"
def nominatim(query):
time.sleep(1.1) # at most one request a second
reply = session.get("https://nominatim.openstreetmap.org/search", timeout=60,
params={"q": query, "format": "jsonv2", "limit": 1, "countrycodes": "gb"})
reply.raise_for_status()
found = reply.json()
return found[0] if found else None
if not os.path.exists("geocoded.json"):
rows = []
for e in sample.itertuples():
lines = [x for x in (e.AddressLine1, e.AddressLine2, e.AddressLine3, e.AddressLine4) if x]
attempts = [", ".join(lines + [e.PostCode]), ", ".join(lines[1:] + [e.PostCode]), e.PostCode]
hit, attempt = None, None
for attempt, query in enumerate(attempts, 1):
hit = nominatim(query)
if hit:
break
rows.append({"fhrsid": e.FHRSID, "attempt": attempt if hit else None,
"addresstype": hit["addresstype"] if hit else None,
"lat": float(hit["lat"]) if hit else None, "lon": float(hit["lon"]) if hit else None,
"fsa_lat": e.geocode["latitude"], "fsa_lon": e.geocode["longitude"]})
json.dump(rows, open("geocoded.json", "w"))
geo = pd.DataFrame(json.load(open("geocoded.json")))
print(f"{len(est):,} Herefordshire food businesses; {len(geo)} sampled; "
f"{geo.addresstype.notna().sum()} matched, by attempt {geo.attempt.value_counts().sort_index().to_dict()}")
print("Nominatim addresstype:", geo.addresstype.value_counts(dropna=False).to_dict())
1,784 Herefordshire food businesses; 300 sampled; 300 matched, by attempt {1: 175, 2: 109, 3: 16}
Nominatim addresstype: {'road': 131, 'amenity': 61, 'postcode': 43, 'village': 17, 'shop': 9, 'town': 8, 'building': 7, 'farmyard': 5, 'city': 5, 'leisure': 4, 'suburb': 3, 'industrial': 2, 'retail': 1, 'hamlet': 1, 'craft': 1, 'commercial': 1, 'highway': 1}
Example 1 โ normalising provider vocabularies
NOMINATIM_LEVELS = {
"house": "rooftop", "building": "rooftop", "office": "rooftop",
"amenity": "rooftop", "shop": "rooftop", "place_of_worship": "rooftop",
"leisure": "rooftop", "craft": "rooftop",
"road": "street", "residential": "street", "footway": "street", "highway": "street",
"postcode": "postcode",
# land-use areas: a farmyard or an industrial estate, hundreds of metres across
"farmyard": "postcode", "industrial": "postcode", "retail": "postcode", "commercial": "postcode",
"suburb": "locality", "neighbourhood": "locality", "hamlet": "locality", "village": "locality",
"town": "locality", "city": "locality", "municipality": "locality",
"county": "region", "state": "region", "province": "region",
"country": "country",
}
TYPICAL_ERROR_M = { # order-of-magnitude, for uncertainty circles
"rooftop": 10, "street": 60, "postcode": 500,
"locality": 5_000, "region": 50_000, "country": 400_000,
}
def to_level(provider: str, raw_level: str | None) -> str:
if provider == "nominatim":
return NOMINATIM_LEVELS.get(raw_level, "unknown")
raise ValueError(f"no level mapping for provider {provider!r}")
The TYPICAL_ERROR_M table is deliberately coarse. Its job is to make the uncertainty visible on a map, not to be a rigorous error model โ and the values come from the measured ladder above rather than from optimism.
Example 2 โ a precision audit of a geocoded file
import pandas as pd
def precision_audit(df: pd.DataFrame, level_col="precision") -> pd.DataFrame:
counts = df[level_col].value_counts(dropna=False)
order = ["rooftop", "street", "postcode", "locality", "region", "country", "unknown"]
table = pd.DataFrame({
"rows": [counts.get(level, 0) for level in order],
}, index=order)
table["pct"] = (100 * table["rows"] / len(df)).round(1)
table["typical_error_m"] = [TYPICAL_ERROR_M.get(level) for level in order]
table["cumulative_pct"] = table["pct"].cumsum().round(1)
return table
geo["precision"] = [to_level("nominatim", t) if t else "unknown" for t in geo.addresstype]
print(precision_audit(geo).to_string())
rows pct typical_error_m cumulative_pct
rooftop 82 27.3 10.0 27.3
street 132 44.0 60.0 71.3
postcode 52 17.3 500.0 88.6
locality 34 11.3 5000.0 99.9
region 0 0.0 50000.0 99.9
country 0 0.0 400000.0 99.9
unknown 0 0.0 NaN 99.9
The cumulative_pct column is the one to quote: "71.3% of this file is street level or better" is a sentence a stakeholder can act on. On these 300 real rural addresses, barely a quarter reached rooftop, and one in nine came back as a village or town.
Example 3 โ drawing the uncertainty instead of hiding it
import geopandas as gpd
def uncertainty_circles(points: gpd.GeoDataFrame, level_col="precision",
projected_crs=3857) -> gpd.GeoDataFrame:
"""One buffer per point, sized by the precision level it was matched at."""
out = points.to_crs(projected_crs).copy()
out["radius_m"] = out[level_col].map(TYPICAL_ERROR_M).fillna(1_000)
out["geometry"] = out.buffer(out["radius_m"])
return out
def plot_by_precision(points, ax, level_col="precision"):
styles = {
"rooftop": dict(marker="o", markersize=18, color="#14b8a6"),
"street": dict(marker="o", markersize=14, color="#0ea5e9"),
"postcode": dict(marker="s", markersize=12, color="#d97706"),
"locality": dict(marker="^", markersize=14, color="#ef4444"),
"country": dict(marker="X", markersize=22, color="#ef4444"),
}
for level, style in styles.items():
subset = points[points[level_col] == level]
if len(subset):
subset.plot(ax=ax, label=f"{level} ({len(subset)})", **style)
ax.legend(title="match precision", fontsize=8)
pts = gpd.GeoDataFrame(geo, geometry=gpd.points_from_xy(geo.lon, geo.lat), crs=4326)
circles = uncertainty_circles(pts, projected_crs=27700)
print(circles.groupby("precision").radius_m.agg(["count", "first"]).to_string())
count first
precision
locality 34 5000
postcode 52 500
rooftop 82 10
street 132 60
Use a projected CRS for the buffers. Buffering degrees produces ellipses that shrink towards the poles, which is a different kind of wrong answer about uncertainty.
Example 4 โ how far each level lands from the published point
from pyproj import Geod
geod = Geod(ellps="WGS84")
ok = geo.fsa_lat.notna() & geo.lat.notna()
g = geo[ok].copy()
g["off_m"] = geod.inv(g.lon.to_numpy(), g.lat.to_numpy(),
g.fsa_lon.astype(float).to_numpy(), g.fsa_lat.astype(float).to_numpy())[2]
print(f"{len(g)} rows with both coordinates")
print(g.groupby("precision").off_m.describe(percentiles=[0.5, 0.9])[["count", "50%", "90%", "max"]]
.round(0).to_string())
print("by fallback attempt:", g.groupby("attempt").off_m.median().round(0).to_dict())
275 rows with both coordinates
count 50% 90% max
precision
locality 27.0 1631.0 18192.0 135387.0
postcode 46.0 69.0 423.0 827.0
rooftop 77.0 58.0 736.0 105951.0
street 125.0 67.0 336.0 203585.0
by fallback attempt: {1: 56.0, 2: 201.0, 3: 71.0}
The medians follow the ladder โ tens of metres for rooftop, street and postcode matches, over a kilometre for localities โ but the maxima do not. A "rooftop" match sat 106 km from the business it was meant to be, and a "street" match 204 km: a building or road with the right name in the wrong town. The level says how big the matched thing is; only a check against something independent says whether it is the right thing. Rows that needed the second attempt, without the business name, landed a median 201 m away against 56 m for full-address matches.
Example 5 โ one address at every level
door = None
for query in ("10 Downing Street, London", "Downing Street, London", "SW1A 2AA",
"London", "United Kingdom"):
hit = nominatim(query)
south, north, west, east = map(float, hit["boundingbox"])
point = (float(hit["lat"]), float(hit["lon"]))
door = door or point
print(f"{query:27} {hit['addresstype']:10} span {geod.inv(west, south, east, north)[2]:12,.0f} m "
f"from the door {geod.inv(door[1], door[0], point[1], point[0])[2]:10,.0f} m "
f"({point[0]:.6f}, {point[1]:.6f})")
10 Downing Street, London office span 55 m from the door 0 m (51.503488, -0.127696)
Downing Street, London road span 98 m from the door 50 m (51.503209, -0.127128)
SW1A 2AA postcode span 1,978 m from the door 0 m (51.503488, -0.127696)
London city span 73,966 m from the door 440 m (51.507446, -0.127765)
United Kingdom country span 1,618,789 m from the door 413,682 m (54.702354, -3.276575)
Why postcode and locality levels mislead
Why the postcode level is the most misleading
Postcode geocodes feel precise because postcodes are precise-looking codes. The precision of the code has nothing to do with the precision of the coordinate.
A UK postcode covers roughly fifteen addresses, and the measured postal unit spanned 1,978 m โ because postcodes follow delivery routes, not compact shapes. A US ZIP code covers thousands of addresses across an area that can exceed a hundred square kilometres, and ZIP "boundaries" are reconstructions rather than official geography.
The practical rule: a postcode centroid is a fine unit of analysis for a report about postcodes, and a poor location for anything about individual addresses.
Why the locality level fools everyone at least once
The measured "London" match sat 440 m from the true address. That looks acceptable โ until you notice the feature it matched spans 74.0 km.
The 440 m was luck: the address is near the city centre, and the centroid is near the city centre. A different address, out in the suburbs, would have inherited the same coordinate and been 20 km wrong. This is why per-row error checks against a sample can pass while the level is systematically unusable: the errors depend on where in the locality each address actually is.
Why street interpolation is biased rather than noisy
Interpolation assumes house numbers are evenly spaced along the segment. Real streets are not: a terrace of small houses at one end and a school at the other pushes every interpolated number towards the terrace.
The error is therefore correlated within a street, not independent per address. Averaging many interpolated points does not cancel it out, which matters when you aggregate them into small areas.
Why the level must travel with the coordinate
Once the precision column is dropped, no downstream process can recover it. The coordinate is complete and plausible; there is no test that distinguishes a rooftop match from a locality centroid except stacking โ many rows sharing one coordinate โ and that only catches the extreme cases.
Two columns, precision and provider, cost a few bytes per row and are the difference between a dataset you can audit and a dataset you have to re-geocode.
Centroids outside polygons and rooftops
- A centroid can be outside its own polygon โ a crescent-shaped locality or a country like Chile. Use a point-on-surface if you must generate one yourself.
- Rooftop does not mean the entrance. For routing, the access point can be a hundred metres from the address point.
- Interpolated ranges can be reversed, putting odd numbers on the wrong side of the street.
- Large sites โ campuses, industrial estates, airports โ return one point for hundreds of doors, at "rooftop" precision.
- Flats and units usually share the building's coordinate; the vertical dimension does not exist in the reference data.
- PO boxes geocode to the sorting office at rooftop precision. The level is honest; the meaning is not.
- Stacked coordinates are the fingerprint of a fallback. Count duplicate coordinates as a routine check.
- Precision is not accuracy. A rooftop match against a badly digitised register is precise and wrong.
Internal links
- Geocoding explained: from an address string to a coordinate โ where the levels come from
- Match quality explained: reading a geocoder's confidence score โ the other half of the quality picture
- How to validate geocoding results before you trust them โ testing the level against geometry
- Fixing geocodes that all land in the country centroid โ the worst level, diagnosed
- How to reverse geocode points to addresses and areas โ the same ladder, in the other direction
- Coordinate precision explained โ decimal places are not accuracy either
- How to batch geocode thousands of addresses in Python โ recording the level at volume
- Spatial data quality dimensions โ positional accuracy in context
FAQ
What does "rooftop" precision mean?
The geocoder matched an address point stored for that specific property, usually from an address register. Expect an error of metres โ but it is a point on the property, not necessarily the entrance.
Is a postcode centroid good enough?
For reports about postcodes, yes. For anything about individual addresses, no: the measured postal unit spanned 1,978 m, and US ZIP codes cover thousands of addresses.
How far wrong can a locality centroid be?
Anywhere within the locality. The measured "London" match had a 74.0 km bounding box; it happened to be 440 m from the target address, and would have been tens of kilometres out for a suburban one.
Which precision do I need for a catchment analysis?
Street level at minimum, rooftop if the catchments are small. A postcode centroid crosses small-area boundaries often enough to change the answer.
How do I show precision on a map?
Symbolise by level and, where it matters, draw a buffer sized by the typical error for that level. It moves the data-quality conversation to the start of the meeting.
Can I improve the precision of an existing geocoded file?
Only by re-geocoding the coarse rows, usually against better reference data or after cleaning the address text. There is no way to refine a centroid into a rooftop after the fact.