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.

A Darwin Core occurrence record with its taxonomic, spatial, temporal and provenance fields. one record, four groups of fieldswhatscientificName: Lynx canadensistaxonKey: 2435263 Β· rank: SPECIESwheredecimalLatitude / decimalLongitudecoordinateUncertaintyInMeters: 1041wheneventDate: 1963-02-11year / month / daywho and howbasisOfRecord: PRESERVED_SPECIMENdatasetKey Β· institutionCode Β· issues[]only the second group is a position, and it comes with a radius spatialworkflow.io
One record, four groups of fields, and only one of them is a position.

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.

basisOfRecord values with what each means as evidence. five kinds of evidence in one columnbasisOfRecordwhat it iswhat it supportsPRESERVED_SPECIMENa physical specimen in a collectionidentity verifiable, position vagueHUMAN_OBSERVATIONsomeone saw or photographed itposition precise, identity variesMACHINE_OBSERVATIONa camera trap, a detector, a loggerboth, if documentedMATERIAL_SAMPLEtissue, DNA, an environmental sampleidentity via sequenceFOSSIL_SPECIMENa fossilnot the present distributionmixing them without saying so is the commonest error in this dataCanada lynx worldwide: 4,813 specimens, 1,483 observations, 28 machine records spatialworkflow.io
Five kinds of evidence in one column; they are not interchangeable.

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.

Black bear records in British Columbia per decade. records per decade, black bear in BC1980s4 records1990s152000s1192010s1,1472020s (6 years)5,919 β€” 81% of the total03000600098% from one platformFour fifths of a century of records were made in the last six years spatialworkflow.io
Four fifths of a century's records were made in the last six years.

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

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.