Species Occurrence Data Explained
On this page
Problem statement
An occurrence record says that somebody recorded a particular organism at a particular place and time. That is all it says. It is not a survey, it does not mean the species was absent anywhere else, and the "place" can be a GPS fix, a park name a volunteer typed, or the centroid of a country.
GBIF aggregates billions of these from thousands of publishers, and they arrive in one schema β Darwin Core β with the differences between a museum specimen from 1903 and a phone photo from last Tuesday preserved in the fields rather than resolved. Reading those fields is most of the work.
Quick answer
Match the name, count what exists, then pull the records with their metadata:
import pandas as pd, requests
API = "https://api.gbif.org/v1"
def match(name):
return requests.get(f"{API}/species/match", params={"name": name}, timeout=60).json()
def count(**params):
return requests.get(f"{API}/occurrence/search",
params={**params, "limit": 0}, timeout=120).json()["count"]
taxon = match("Ursus americanus")
print(f"{taxon['scientificName']}: key {taxon['usageKey']}, "
f"match {taxon['matchType']}, rank {taxon['rank']}")
for where in [{}, {"country": "CA"},
{"country": "CA", "stateProvince": "British Columbia"},
{"country": "CA", "stateProvince": "British Columbia", "hasCoordinate": "true"}]:
label = ", ".join(f"{k}={v}" for k, v in where.items()) or "worldwide"
print(f" {label:58} {count(taxonKey=taxon['usageKey'], **where):>9,}")
Ursus americanus Pallas, 1780: key 2433407, match EXACT, rank SPECIES
worldwide 65,933
country=CA 18,285
country=CA, stateProvince=British Columbia 7,861
country=CA, stateProvince=British Columbia, hasCoordinate=true 7,560
Each filter is a decision about what the data means. hasCoordinate alone removes every record that was never georeferenced β which is not the same as removing the bad coordinates.
Resolve the name, read the basis, treat coordinates as claims
1. Resolve the name before anything else
Names change, and GBIF matches yours to its backbone taxonomy. A FUZZY or HIGHERRANK match means you are querying something other than what you typed.
2. Read basisOfRecord
A preserved specimen, a human observation, a machine observation and a fossil are different kinds of evidence with different reliability, and mixing them silently is the most common mistake in this data.
3. Treat the coordinate as a claim with a radius
coordinateUncertaintyInMeters is part of the coordinate. Where it is missing β and it often is β you do not know how precise the position is.
4. Read the issue flags
GBIF publishes a list of issues per record: rounded coordinates, assumed datum, coordinates that disagree with the stated country. They are warnings, not rejections.
5. Look at who published it, and when
One dataset frequently dominates a region. When it does, the distribution you are mapping is largely that dataset's sampling.
6. Cite what you used
Every record carries a datasetKey, and a real analysis cites the datasets or a download DOI. It is also the only way to reproduce the same query later.
What a regional GBIF pull actually contains
The data used on this page
Occurrence records come 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 β what a regional pull actually contains
from gbif import search # the paging helper from the how-to guide
bears = search(taxonKey=taxon["usageKey"], country="CA",
stateProvince="British Columbia", hasCoordinate="true")
print(f"{len(bears):,} georeferenced records")
print(bears.basisOfRecord.value_counts().to_string())
print(f"\ntop publishers: {bears.institutionCode.value_counts().head(3).to_dict()}")
print(f"records per decade:")
print((bears.year // 10 * 10).value_counts().sort_index().tail(5).to_string())
7,560 georeferenced records
basisOfRecord
HUMAN_OBSERVATION 7427
PRESERVED_SPECIMEN 119
MATERIAL_SAMPLE 13
MATERIAL_CITATION 1
top publishers: {'iNaturalist': 7427, 'UWBM': 40, 'MVZ': 40}
records per decade:
year
1980.0 4
1990.0 15
2000.0 119
2010.0 1149
2020.0 6160
Ninety-eight per cent of these records come from one platform, and four fifths of them were made in the last six years. That is not a change in the bears.
Example 2 β the uncertainty field, where it exists
u = bears.coordinateUncertaintyInMeters
print(f"records with an uncertainty: {u.notna().sum():,} of {len(bears):,} "
f"({u.notna().mean():.1%})")
print(f"median {u.median():,.0f} m, 90th percentile {u.quantile(0.9):,.0f} m, "
f"max {u.max():,.0f} m")
for cut in (100, 1_000, 10_000, 100_000):
print(f" within {cut:>7,} m: {(u <= cut).sum():>6,} records "
f"({(u <= cut).mean():.1%} of all)")
print(f"\nthe largest uncertainty is {u.max() / 1000:,.0f} km β "
f"a circle covering {3.14159 * (u.max() / 1000) ** 2 / 1e6:,.0f} million kmΒ²")
records with an uncertainty: 6,598 of 7,560 (87.3%)
median 43 m, 90th percentile 13,897 m, max 8,929,687 m
within 100 m: 3,855 records (51.0% of all)
within 1,000 m: 5,194 records (68.7% of all)
within 10,000 m: 5,894 records (78.0% of all)
within 100,000 m: 6,540 records (86.5% of all)
the largest uncertainty is 8,930 km β a circle covering 251 million kmΒ²
The median is 43 metres and the distribution has a very long tail: half the records are precise to a hundred metres, and about one in a hundred and thirty claims a radius larger than a hundred kilometres. The single worst is a circle covering half the planet β a record whose position is, honestly stated, unknown. Filtering on the median would keep it; filtering on the uncertainty removes it.
Example 3 β the issue flags
flags = pd.Series([i for row in bears.issues.dropna() for i in row]).value_counts()
print(flags.head(6).to_string())
print(f"\nrecords with no flags at all: "
f"{sum(1 for row in bears.issues if not row):,}")
rounded = bears[bears.issues.map(lambda r: "COORDINATE_ROUNDED" in (r or []))]
print(f"COORDINATE_ROUNDED affects {len(rounded):,} records "
f"({len(rounded) / len(bears):.0%})")
CONTINENT_DERIVED_FROM_COORDINATES 7521
TAXON_ID_NOT_FOUND 7433
COORDINATE_ROUNDED 6870
INSTITUTION_MATCH_FUZZY 94
INSTITUTION_COLLECTION_MISMATCH 81
GEODETIC_DATUM_ASSUMED_WGS84 28
records with no flags at all: 2
COORDINATE_ROUNDED affects 6,870 records (91%)
COORDINATE_ROUNDED on nine records in ten means the publisher truncated the decimals β often to protect a sensitive species, sometimes because a spreadsheet did it. Either way the position is coarser than its decimal places suggest.
Presence-only, and why the publisher matters
Why presence-only is the defining property
Almost every occurrence record is a presence. There is no matching record for "I looked here and found nothing", so absence has to be inferred, and every inference is a modelling assumption. Most of the statistical machinery around this data exists to work around that one missing field.
Why the publisher matters as much as the species
A regional dataset is a sample of effort as much as of nature. When one platform contributes 98% of the records, the map is a map of where that platform's users went β trailheads, roadsides, parks, weekends. The species distribution is somewhere underneath it.
Why Darwin Core keeps the differences
The standard could have forced every publisher into one level of precision and thrown the rest away. Instead it keeps basisOfRecord, coordinateUncertaintyInMeters, georeferenceProtocol, identificationVerificationStatus and several dozen more, so that a user can decide what is fit for their purpose. The cost is that the decision cannot be avoided.
Sensitive species, absences and duplicates
- Sensitive species have coordinates deliberately blurred or withheld; the flag is in the record, not in the coordinate.
- Absence records do exist in some datasets via
occurrenceStatus=ABSENT; filter them out or use them deliberately. individualCountis often null and occasionally huge; a record is not one animal.- Duplicates across publishers are common β the same specimen catalogued twice.
- eBird and iNaturalist dominate bird and general observation data respectively, with different protocols.
- Licences vary by dataset (CC0, CC-BY, CC-BY-NC); an aggregated product inherits the most restrictive one.
Internal links
- How to download GBIF occurrences in Python β the API and its limits
- Sampling bias in occurrence data explained β why the map is a map of effort
- Coordinate uncertainty explained β the radius on every position
- How to clean species occurrence records β the filters worth applying
- Species distribution models explained β what presence-only data supports
- How to download data from a WFS service in Python β another biodiversity data route
FAQ
What is an occurrence record?
Evidence that a taxon was recorded at a place and time β a specimen, an observation, a photograph or a detection.
Does an occurrence mean the species lives there?
It means somebody recorded it there. Vagrants, escapes, misidentifications and mislocated records are all in the data.
What is Darwin Core?
The standard that gives occurrence records a shared set of fields, so aggregators like GBIF can combine them without discarding the differences.
Why are there no absence records?
Because most sources never record them. Absence in presence-only data is an assumption, not an observation.
How accurate are the coordinates?
It varies from metres to whole countries, and coordinateUncertaintyInMeters is how you tell β where it is populated.
Do I need to cite the data?
Yes. Each record has a datasetKey, and a GBIF download has a DOI; both the publishers and reproducibility depend on it.