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.

Three occurrence records with different uncertainty radii drawn over a grid. the radius each record actually claims30 m10 km100 kmgrid cells are 1 km; the record in the middle spans ten of themhalf of these records have no uncertainty recorded at all spatialworkflow.io
The same three points, with the radius each one actually claims.

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.

Share of records usable at grid sizes from 1 km to 50 km. usable records by analysis grain1 km grid814 records (13%)5 km grid1,610 (25%)10 km grid2,011 (32%)50 km grid2,664 (42%)025%50%radius <= half a cell6,353 Canada lynx records; 35% carry no uncertainty and are excluded throughout spatialworkflow.io
How much of a dataset is usable depends entirely on the grain you chose.

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.

The most frequent uncertainty values shown as round numbers in another unit. the values are round, in milesvalue (m)what it really isrecords1,041a georeferencing tool default432 (10.5%)64,374exactly 40 miles262 (6.4%)80,467exactly 50 miles139 (3.4%)120,701exactly 75 miles106 (2.6%)15,000a round 15 km88 (2.1%)29% of the values are a whole number of miles; another 22% a whole number of kmHalf of this field is somebody’s estimate to the nearest convenient unit spatialworkflow.io
The most common values are round numbers in another unit: 64,374 m is forty miles.

Precision fields, zeros and generalised points

  • coordinatePrecision is 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.

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.