Occurrence records fall in the ocean
On this page
Problem statement
A terrestrial species has records in the Pacific, a cluster at 0Β°N 0Β°E in the Gulf of Guinea, and a line of points a kilometre offshore all along a coast. None of those is the species swimming.
They are four different bugs with four different fixes: a missing coordinate written as zero, a transposed or negated sign, a coarse land mask that cannot resolve a coastline, and a genuine georeferencing error. Treating them as one problem β "drop anything not on land" β throws away real coastal records and keeps the ones that matter least.
Quick answer
Classify the offshore records by how far offshore they are:
import io, os, zipfile
import numpy as np, geopandas as gpd, requests
from gbif import search
def natural_earth(name, url):
path = f"bio/{name}.gpkg"
if not os.path.exists(path):
blob = requests.get(url, timeout=300).content
zipfile.ZipFile(io.BytesIO(blob)).extractall(f"bio/{name}")
shp = [f for f in os.listdir(f"bio/{name}") if f.endswith(".shp")][0]
gpd.read_file(f"bio/{name}/{shp}").to_file(path, driver="GPKG")
return gpd.read_file(path)
land = natural_earth("ne_land",
"https://naciscdn.org/naturalearth/110m/physical/ne_110m_land.zip")
lynx = search(taxonKey=2435263, hasCoordinate="true")
pts = gpd.GeoDataFrame(lynx, crs=4326,
geometry=gpd.points_from_xy(lynx.decimalLongitude,
lynx.decimalLatitude))
on_land = gpd.sjoin(pts, land, predicate="within", how="left").index_right.notna().values
sea = pts[~on_land].to_crs(6933)
coast = land.to_crs(6933).boundary.union_all()
offshore_km = sea.distance(coast) / 1000
print(f"{len(pts):,} records, {len(sea):,} outside the 110 m land polygons")
for lo, hi, label in [(0, 5, "within 5 km of the coast"),
(5, 50, "5β50 km offshore"),
(50, 1e6, "more than 50 km offshore")]:
sel = (offshore_km >= lo) & (offshore_km < hi)
print(f" {label:<28} {sel.sum():>4} records")
zeros = (pts.decimalLatitude.abs() < 0.01) & (pts.decimalLongitude.abs() < 0.01)
print(f"at 0,0 exactly: {int(zeros.sum())}")
6,353 records, 68 outside the 110 m land polygons
within 5 km of the coast 32 records
5β50 km offshore 29 records
more than 50 km offshore 7 records
at 0,0 exactly: 3
The distribution of distances is the diagnosis. A record 500 m offshore is a mask artefact; a record 500 km offshore is an error with a cause worth finding.
Exact zeros, sign errors, a matching land mask
1. Check for exact zeros first
0, 0 is a missing coordinate that a spreadsheet filled in. GBIF flags it as ZERO_COORDINATE; so does a one-line test.
2. Test for sign errors
Negate the longitude, the latitude, or both, and see whether the record lands on plausible habitat inside the species' known range. A cluster that mirrors an existing one across the equator or the prime meridian is diagnostic.
3. Use a land mask that matches your tolerance
A 1:110 m coastline is tens of kilometres from the truth in places. A finer layer does not automatically flag fewer records β it resolves lakes and inlets that the coarse one fills in β so compare the two and look at what moved.
4. Buffer the coast rather than dropping
A small positive buffer on the land polygons keeps genuine coastal records and removes the rest. Choose the buffer to match the mask's resolution, and say what it was.
5. Look at what is left by dataset
Offshore records are rarely random: a handful of datasets produce most of them, and the pattern within a dataset names the cause.
6. Report them rather than deleting them
They are evidence about the publisher's georeferencing and worth sending back.
Finer coastlines and sign tests
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 β a finer coastline changes the count
fine = natural_earth("ne_land_10m",
"https://naciscdn.org/naturalearth/10m/physical/ne_10m_land.zip")
for name, layer in [("110 m (coarse)", land), ("10 m (fine)", fine)]:
hit = gpd.sjoin(pts, layer, predicate="within", how="left").index_right.notna()
print(f"{name:<16} {(~hit).sum():>5,} records not on land "
f"({(~hit).mean():.2%})")
buffered = fine.to_crs(6933).buffer(2_000).union_all()
in_buffer = pts.to_crs(6933).within(buffered)
print(f"{'10 m + 2 km buffer':<16} {(~in_buffer).sum():>5,} records not on land "
f"({(~in_buffer).mean():.2%})")
110 m (coarse) 68 records not on land (1.07%)
10 m (fine) 90 records not on land (1.42%)
10 m + 2 km buffer 44 records not on land (0.69%)
The finer coastline flags more records, not fewer: 90 against 68. That is the opposite of the expected result and it has a cause β the 1:10 m layer resolves large lakes as water, and a lynx photographed on a frozen lake or from a boat is now "not on land". Only the buffer reduces the count, to 44, and it does so by accepting everything within two kilometres of any shoreline. Picking the mask that gives the fewest flags is how a cleaning step becomes a way of not seeing the problem.
Example 2 β testing for sign errors
suspects = pts[~on_land].copy()
variants = {
"as recorded": (suspects.decimalLongitude, suspects.decimalLatitude),
"longitude negated": (-suspects.decimalLongitude, suspects.decimalLatitude),
"latitude negated": (suspects.decimalLongitude, -suspects.decimalLatitude),
"both negated": (-suspects.decimalLongitude, -suspects.decimalLatitude),
"swapped": (suspects.decimalLatitude, suspects.decimalLongitude),
}
fine_union = fine.union_all()
for label, (lon, lat) in variants.items():
moved = gpd.GeoSeries(gpd.points_from_xy(lon, lat), crs=4326)
print(f" {label:<20} {moved.within(fine_union).sum():>4} of {len(suspects)} "
f"would fall on land")
as recorded 39 of 68 would fall on land
longitude negated 58 of 68 would fall on land
latitude negated 6 of 68 would fall on land
both negated 10 of 68 would fall on land
swapped 8 of 68 would fall on land
Negating the longitude puts 58 of the 68 on land β and that result means nothing, because negating a North American longitude lands in Asia, which is mostly land. The test only works against the species' known range, not against land in general: run it, then check whether the rescued records fall inside the range the rest of the data describes. Here they do not, so the sign hypothesis is rejected and the records are simply imprecise or genuinely on water.
Example 3 β which datasets produce them
offshore = pts[~pts.to_crs(6933).within(buffered)].copy()
print(f"{len(offshore)} records more than 2 km from the fine coastline")
by_dataset = offshore.datasetKey.value_counts().head(5)
for key, n in by_dataset.items():
total = (pts.datasetKey == key).sum()
print(f" {key[:8]}β¦ {n:>4} offshore of {total:>5,} records "
f"({n / total:.1%} of that dataset)")
print(f"\ndatasets contributing offshore records: {offshore.datasetKey.nunique()} "
f"of {pts.datasetKey.nunique()}")
44 records more than 2 km from the fine coastline
4bfac3ea⦠15 offshore of 61 records (24.6% of that dataset)
377be098β¦ 14 offshore of 3,281 records (0.4% of that dataset)
50c9509d⦠10 offshore of 1,472 records (0.7% of that dataset)
bb109a7a⦠2 offshore of 490 records (0.4% of that dataset)
f86a681d⦠1 offshore of 148 records (0.7% of that dataset)
datasets contributing offshore records: 7 of 54
Why coarse coastlines mislead
Why coarse coastlines produce so many false positives
A 1:110 m coastline generalises fjords, islands, spits and estuaries away entirely, so any record on one of them is "in the sea". For a country like Canada, with an enormously convoluted coast and thousands of islands, the coarse mask is wrong over huge areas β and a species whose habitat is coastal will lose a disproportionate share of its records to it.
Why the zero coordinate is a special case
0, 0 is in the Gulf of Guinea and is what many systems write when a coordinate is absent. It is not a georeferencing error; it is a missing value with a numeric type. Because it is a legitimate position, no geometric test distinguishes it β it needs its own check, which is why GBIF gives it its own flag.
Why a buffer beats a reprojection
The instinct on finding coastal records offshore is to reproject or re-snap them onto land. That moves data to fit a reference layer, which is backwards: the coastline is the approximation and the record's coordinate is the observation. A buffer accepts records near the mask's boundary without changing them, and leaves the original coordinates intact for whoever has a better coastline.
Country centroids, inland water and marine datasets
- Country centroids are the land-based equivalent: a whole country's records at one point.
- Sea-crossing species β seals, seabirds, migrating insects β have legitimate offshore records.
- Inland water: some land layers exclude large lakes, so lake records look offshore too.
- Coordinate precision: a record rounded to 0.1Β° can be 11 km out, which puts coastal records in the sea by construction.
COUNTRY_COORDINATE_MISMATCHcatches many sign errors before you have to test for them.- Marine datasets joined to a terrestrial mask will look catastrophically wrong; check the taxon first.
Internal links
- How to clean species occurrence records β where this check belongs
- Species occurrence data explained β the flags GBIF already computes
- Coordinate uncertainty explained β why precise-looking coordinates are not
- A GBIF download is missing records β the other way records go wrong
- A range polygon has the wrong shape β what one offshore record does to a hull
- How to snap and align geometries to fix slivers and gaps in Python β why snapping to a reference is risky
FAQ
Why are my terrestrial records in the ocean?
Zero coordinates, sign errors, a coarse land mask, or genuine georeferencing errors β and the distance offshore separates them.
Should I just drop everything not on land?
No. Most "offshore" records on a coarse mask are coastal records; a finer coastline or a small buffer keeps them.
How do I detect a sign error?
Negate the longitude, the latitude, and both, and see whether the record lands on plausible habitat.
What is the 0,0 cluster?
Missing coordinates written as zeros. GBIF flags it as ZERO_COORDINATE.
What buffer should I use?
One that matches the coastline's resolution β a couple of kilometres for a 1:10 m layer.
Should I move the records onto land?
No. Record the problem, keep the original coordinate, and report it to the publisher.