Coordinate Uncertainty Explained
On this page
Problem statement
Every occurrence record's position is a claim with a radius, and Darwin Core has a field for it: coordinateUncertaintyInMeters. A GPS fix under open sky is 5 m. A locality georeferenced from "20 km NE of Prince George" might be 15 km. A record placed at the centroid of a national park is however big the park is.
The field is optional, frequently missing, and almost always ignored. Ignoring it means treating a point that could be anywhere in a county as if it were surveyed β and then binning it into a 1 km grid cell.
Quick answer
Look at the distribution before deciding anything:
import numpy as np, pandas as pd
from gbif import search
lynx = search(taxonKey=2435263, hasCoordinate="true") # Lynx canadensis
u = lynx.coordinateUncertaintyInMeters
print(f"{len(lynx):,} records; {u.notna().sum():,} carry an uncertainty "
f"({u.notna().mean():.0%})")
for q in (0.1, 0.25, 0.5, 0.75, 0.9, 0.99):
print(f" {int(q * 100):>3}th percentile: {u.quantile(q):>12,.0f} m")
print(f" maximum: {u.max():>12,.0f} m")
for cell in (1_000, 5_000, 10_000, 50_000):
ok = (u <= cell / 2).sum()
print(f"usable in a {cell // 1000:>2} km grid (radius <= half a cell): "
f"{ok:>5,} records ({ok / len(lynx):.0%})")
6,353 records; 4,136 carry an uncertainty (65%)
10th percentile: 20 m
25th percentile: 1,041 m
50th percentile: 7,072 m
75th percentile: 40,000 m
90th percentile: 80,467 m
99th percentile: 160,934 m
maximum: 4,800,000 m
usable in a 1 km grid (radius <= half a cell): 814 records (13%)
usable in a 5 km grid (radius <= half a cell): 1,610 records (25%)
usable in a 10 km grid (radius <= half a cell): 2,011 records (32%)
usable in a 50 km grid (radius <= half a cell): 2,664 records (42%)
A record whose uncertainty exceeds half the cell size cannot be assigned to a cell. It can be assigned to a cell, which is what most workflows do.
A radius, a decision about missing values, a grain to compare
1. Treat the value as a radius, not a rating
It is the radius of a circle centred on the given coordinate that the georeferencer believed contained the true location. The units are metres, always.
2. Decide what missing means
Missing is not zero. It means the publisher did not record it, which is most common in older and bulk-loaded data.
3. Compare it with your analysis grain
An uncertainty larger than half a cell means the record could belong to a neighbouring cell. That test, applied honestly, removes a lot of data.
4. Watch for converted round numbers
Values look arbitrary and are not: 64,374 m is forty miles. Half of these values are a whole number of miles or kilometres, which tells you how they were produced.
5. Treat enormous values as missing
A radius of thousands of kilometres is a statement that the position is unknown, not a wide but usable estimate.
6. Propagate it if you can
Sampling a covariate at a point with a 10 km radius is a point estimate of something that should be a distribution. At minimum, report the median uncertainty of the records a result rests on.
Round-number signatures and what a radius covers
The data used on this page
Occurrence records are pulled from the GBIF occurrence search API by the code on this page. gbif.search is the paging function from How to download GBIF occurrences in Python, saved as a module by the setup. GBIF reprocesses continuously, so the setup answers each request with the reply it got on 9 October 2026; a live pull differs in the last digits.
import hashlib
import io
import os
import zipfile
import requests
# GBIF adds and revises records daily. So that the numbers below reproduce, every GBIF request on
# this page is answered from the reply it got on 9 October 2026 (one file per request, named by a
# SHA-1 of its URL); any other request, or all of them once these lines are deleted, goes live.
SAVED = zipfile.ZipFile(io.BytesIO(requests.get(
"https://data.spatialworkflow.io/gbif-api-2026-10-09.zip", timeout=300).content))
live_get = requests.get
def saved_get(url, params=None, **kwargs):
prepared = requests.Request("GET", url, params=params).prepare()
try:
body = SAVED.read(hashlib.sha1(prepared.url.encode()).hexdigest()[:16] + ".json")
except KeyError:
return live_get(url, params=params, **kwargs)
reply = requests.models.Response()
reply.status_code, reply._content, reply.url = 200, body, prepared.url
reply.headers["Content-Type"] = "application/json"
return reply
requests.get = saved_get
os.makedirs("bio", exist_ok=True)
with open("gbif.py", "w") as f:
f.write('''
import pandas as pd, requests
API = "https://api.gbif.org/v1"
MAX_OFFSET = 100_000
FIELDS = ["key", "species", "speciesKey", "decimalLongitude", "decimalLatitude",
"coordinateUncertaintyInMeters", "year", "month", "basisOfRecord",
"datasetKey", "institutionCode", "issues"]
def count(**params):
return requests.get(f"{API}/occurrence/search", params={**params, "limit": 0}, timeout=120).json()["count"]
def search(limit=None, page=300, **params):
total = count(**params)
if limit is None and total > MAX_OFFSET:
raise ValueError(f"{total:,} records exceeds the {MAX_OFFSET:,} offset limit")
rows, offset = [], 0
target = total if limit is None else min(limit, total)
while offset < target:
body = requests.get(f"{API}/occurrence/search",
params={**params, "limit": min(page, target - offset), "offset": offset},
timeout=180).json()
rows += body["results"]
offset += len(body["results"])
if body["endOfRecords"] or not body["results"]:
break
return pd.DataFrame([{f: r.get(f) for f in FIELDS} for r in rows])
''')
print("gbif.py written")
gbif.py written
Example 1 β the round-number signature
vals = u.dropna()
common = vals.value_counts().head(8)
print("most frequent uncertainty values:")
for v, n in common.items():
print(f" {v:>12,.0f} m {n:>5,} records ({n / len(vals):.1%})")
whole_miles = (np.abs(vals / 1609.344 - (vals / 1609.344).round()) < 0.01)
whole_km = (np.abs(vals / 1000 - (vals / 1000).round()) < 0.01)
print(f"\nvalues that are a whole number of miles: {whole_miles.mean():.0%}")
print(f"values that are a whole number of km: {whole_km.mean():.0%}")
print(f"distinct values: {vals.nunique():,} across {len(vals):,} records")
most frequent uncertainty values:
1,041 m 432 records (10.4%)
64,374 m 262 records (6.3%)
80,467 m 139 records (3.4%)
42,783 m 119 records (2.9%)
4 m 116 records (2.8%)
4,165 m 115 records (2.8%)
120,701 m 106 records (2.6%)
15,000 m 88 records (2.1%)
values that are a whole number of miles: 29%
values that are a whole number of km: 22%
distinct values: 755 across 4,136 records
None of the top values look round, and every one of them is. 64,374 m is forty miles, 80,467 is fifty, 120,701 is seventy-five: a georeferencer typed a round number of miles and a converter wrote metres. Twenty-nine per cent of the values are whole miles and another fifth are whole kilometres, which means half of this field is somebody's estimate to the nearest convenient unit rather than a computed error.
Example 2 β what the radius covers
from math import pi
sample = [30, 1_000, 10_000, 100_000, u.max()]
print(f"{'radius':>12} {'area':>16} {'comparable to':<28} {'records at or below':>20}")
for r in sample:
area = pi * (r / 1000) ** 2
label = ("a city block" if r < 100 else "a village" if r < 2_000
else "a small county" if r < 30_000 else "a large county" if r < 200_000
else "a continent")
print(f"{r:>10,.0f} m {area:>13,.0f} kmΒ² {label:<28} {(u <= r).sum():>14,}")
radius area comparable to records at or below
30 m 0 kmΒ² a city block 450
1,000 m 3 kmΒ² a village 956
10,000 m 314 kmΒ² a small county 2,206
100,000 m 31,416 kmΒ² a large county 3,873
4,800,000 m 72,382,295 kmΒ² a continent 4,136
Example 3 β how much the uncertainty moves a covariate
rng = np.random.default_rng(0)
usable = lynx[u.notna() & (u <= 50_000)].copy()
lat = usable.decimalLatitude.to_numpy()
unc = usable.coordinateUncertaintyInMeters.to_numpy()
# Draw one plausible true position per record, uniformly inside its circle.
angle = rng.uniform(0, 2 * pi, len(usable))
radius = unc * np.sqrt(rng.uniform(0, 1, len(usable)))
shift_lat = (radius * np.sin(angle)) / 111_320
moved = lat + shift_lat
print(f"{len(usable):,} records with an uncertainty under 50 km")
print(f"median latitude shift when a position is redrawn inside its circle: "
f"{np.median(np.abs(shift_lat)) * 111.32:,.2f} km")
print(f"records that would move into a different 10 km band: "
f"{(np.floor(lat * 111.32 / 10) != np.floor(moved * 111.32 / 10)).mean():.1%}")
print(f"records that would move into a different 1 km band: "
f"{(np.floor(lat * 111.32) != np.floor(moved * 111.32)).mean():.1%}")
3,354 records with an uncertainty under 50 km
median latitude shift when a position is redrawn inside its circle: 0.85 km
records that would move into a different 10 km band: 31.8%
records that would move into a different 1 km band: 60.2%
Redrawing each position once inside its own circle moves a third of the records into a different 1 km band. That is the sensitivity of a fine-grained analysis to a field most pipelines never read.
A radius is not a confidence interval
Why the radius is not a confidence interval
The georeferencing guidelines define it as the radius of a circle expected to contain the true position β closer to a bound than to a standard deviation, and applied by people with varying diligence. It cannot be fed into a statistical model as a variance without an assumption, which is why most work uses it as a filter rather than as a weight.
Why missing values are the hard case
Roughly a third of these records have no uncertainty at all, and they are not a random third: they are the older, bulk-digitised, museum-sourced records whose positions are the least precise. Dropping them removes the worst positions and the entire historical baseline; keeping them means the analysis includes positions whose precision is unknown.
Why grain is the real question
Uncertainty only matters relative to what you are doing. At a 50 km grain almost every record is usable; at 1 km most are not. Choosing the grain first and then reporting how many records survive it is honest. Choosing the grain to keep the records is not.
Precision fields, zeros and generalised points
coordinatePrecisionis a different field β the decimal precision of the stored coordinate, not the georeferencing error.- Zero is occasionally recorded and means "we did not think about it" more often than "exact".
- Generalised coordinates for sensitive species may carry a matching uncertainty, or may not.
- Point-radius vs shape: some publishers supply
footprintWKT, which is better than a circle where it exists. - Uncertainty grows with age: pre-GPS localities were georeferenced retrospectively from text.
- A 10 km circle covers 314 kmΒ² β bigger than many protected areas the record might be attributed to.
Internal links
- Species occurrence data explained β the other fields on a record
- How to clean species occurrence records β where this becomes a filter
- Sampling bias in occurrence data explained β the other reason positions mislead
- How to build a species richness grid β where the grain is chosen
- Geocoding precision levels explained β the same idea for addresses
- How to choose a cell size for an interpolated surface β choosing grain deliberately
FAQ
What is coordinateUncertaintyInMeters?
The radius, in metres, of a circle around the recorded coordinate that is expected to contain the true position.
What does a missing value mean?
That the publisher did not record it β not that the position is exact.
How large can it get?
Larger than a country. Values of thousands of kilometres appear and mean the position is effectively unknown.
Can I use records whose uncertainty exceeds my cell size?
You can assign them to a cell, but the assignment is arbitrary. Report how many records that affects.
Is it a standard deviation?
No. It is closer to a bound on the error, applied inconsistently, and should be used as a filter rather than a variance.
What grain should I choose?
Whatever the question needs, chosen before looking at how many records survive it.