Skip to contents

This article runs the whole cleaning workflow on a GBIF download or a similar table: flag every record with all the tests its columns allow, look at what each test caught, and then compare several cleaning rules over the same flags before choosing one. The data are simulated so that every error is known, which makes it possible to check that each test catches what it should and nothing else. Installation is covered in Get started.

Example data

The table has 2,276 records in GBIF’s lower-case Darwin Core column names (decimallongitude, countrycode, basisofrecord, …), which occ_columns() matches without renaming. Of these, 2,000 are background records drawn at random inside a box over each of five European countries, with that country’s code. The boxes follow the borders only roughly: two of the Austrian points fall across the border in Bavaria, and the country test will say so.

The other 276 records carry planted errors, labelled in a column truth so the flags can be checked against it afterwards: herbarium sheets georeferenced to the capital where they are kept, coordinates typed as zeros, the GBIF headquarters in Copenhagen as a default location, swapped and negated coordinates, coordinates entered at whole degrees, botanic-garden specimens and cultivated plants, and 40 exact copies of background records.

Simulation code
library(OccurrenceCleaner)
library(ggplot2)

set.seed(42)
boxes <- data.frame(code = c("AT", "DE", "FR", "ES", "PL"),
                    lon0 = c(13.0, 9.0, 1.0, -6.0, 17.0), lon1 = c(15.5, 12.0, 4.0, -4.5, 21.0),
                    lat0 = c(47.0, 49.5, 45.5, 38.0, 50.5), lat1 = c(48.0, 51.5, 47.5, 40.0, 52.0))
n <- 2000
b <- boxes[sample(nrow(boxes), n, TRUE), ]
spp <- c("Quercus robur", "Fagus sylvatica", "Bufo bufo", "Lucanus cervus", "Amanita muscaria")
occ <- data.frame(
  gbifid = as.character(seq_len(n)),
  species = sample(spp, n, TRUE),
  decimallongitude = round(runif(n, b$lon0, b$lon1), 5),
  decimallatitude = round(runif(n, b$lat0, b$lat1), 5),
  countrycode = b$code,
  year = sample(c(1850:2024), n, TRUE, prob = seq(0.2, 3, length.out = 175)),
  coordinateuncertaintyinmeters = sample(c(NA, 10, 30, 100, 1000, 5000), n, TRUE),
  basisofrecord = sample(c("HUMAN_OBSERVATION", "PRESERVED_SPECIMEN"), n, TRUE, c(0.8, 0.2)),
  establishmentmeans = NA_character_,
  issue = "")
occ$kingdom <- ifelse(grepl("Quercus|Fagus", occ$species), "Plantae",
                      ifelse(occ$species == "Amanita muscaria", "Fungi", "Animalia"))
err <- function(k, truth, lon, lat, code, ...) {
  d <- occ[sample(n, k), ]
  d$gbifid <- paste0(truth, "-", seq_len(k))
  d$decimallongitude <- lon; d$decimallatitude <- lat; d$countrycode <- code
  extra <- list(...)
  for (nm in names(extra)) d[[nm]] <- extra[[nm]]
  d$truth <- truth
  d
}
occ$truth <- "background"
capitals <- data.frame(lon = c(16.37, 2.35, -3.70, 13.40, 21.01), lat = c(48.21, 48.86, 40.42, 52.52, 52.23),
                       code = c("AT", "FR", "ES", "DE", "PL"))
k <- sample(5, 60, TRUE)
bad <- rbind(
  err(60, "capital", capitals$lon[k] + rnorm(60, 0, 0.01), capitals$lat[k] + rnorm(60, 0, 0.01),
      capitals$code[k], basisofrecord = "PRESERVED_SPECIMEN", year = sample(1850:1950, 60, TRUE)),
  err(20, "zero", 0, 0, NA),
  err(15, "gbif_hq", 12.58, 55.67, "DK"),
  err(25, "swapped", occ$decimallatitude[1:25], occ$decimallongitude[1:25], occ$countrycode[1:25],
      issue = "PRESUMED_SWAPPED_COORDINATE"),
  err(25, "negated", -occ$decimallongitude[occ$countrycode == "FR"][1:25],
      occ$decimallatitude[occ$countrycode == "FR"][1:25], "FR"),
  err(30, "rounded", round(occ$decimallongitude[1:30]), round(occ$decimallatitude[1:30]),
      occ$countrycode[1:30], coordinateuncertaintyinmeters = NA),
  err(20, "living", occ$decimallongitude[31:50], occ$decimallatitude[31:50], occ$countrycode[31:50],
      basisofrecord = "LIVING_SPECIMEN"),
  err(21, "cultivated", occ$decimallongitude[51:71], occ$decimallatitude[51:71],
      occ$countrycode[51:71], establishmentmeans = "CULTIVATED"),
  err(20, "fuzzy_taxon", occ$decimallongitude[72:91], occ$decimallatitude[72:91],
      occ$countrycode[72:91], issue = "TAXON_MATCH_FUZZY;COORDINATE_ROUNDED"))
dups <- occ[sample(n, 40), ]; dups$gbifid <- paste0("dup-", 1:40); dups$truth <- "duplicate"
occ <- rbind(occ, bad, dups)

One record of each kind, and the size of each group:

cols <- c("gbifid", "species", "decimallongitude", "decimallatitude", "countrycode", "issue", "truth")
occ[match(c("background", "capital", "swapped"), occ$truth), cols]
#>          gbifid          species decimallongitude decimallatitude countrycode
#> 1             1 Amanita muscaria         14.94154        47.07497          AT
#> 10661 capital-1   Lucanus cervus         13.40015        52.50722          DE
#> 5161  swapped-1    Quercus robur         47.07497        14.94154          AT
#>                             issue      truth
#> 1                                 background
#> 10661                                capital
#> 5161  PRESUMED_SWAPPED_COORDINATE    swapped
table(occ$truth)
#> 
#>  background     capital  cultivated   duplicate fuzzy_taxon     gbif_hq 
#>        2000          60          21          40          20          15 
#>      living     negated     rounded     swapped        zero 
#>          20          25          30          25          20

The issue column lists GBIF’s issue names separated by ;, which is how both the simple-format download and the snapshot read through vectra deliver them; a list column of character vectors works too.

Flagging with every test the columns allow

Without tests, flag_occurrences() runs every test whose columns are in the table. Running a test only adds its flags; which records to exclude is chosen later, as a cleaning rule. occ_columns() only needs to be told the identifier, since the other defaults match case-insensitively:

f <- flag_occurrences(occ, columns = occ_columns(id = "gbifid"))
dim(f)
#> [1] 2276   34

test_plan() lists the tests that ran and the reason each other test did not. The table has coordinates, a species, a country code and the three GBIF fields, so the coordinate, GBIF and species outlier tests run. The fossil tests, the dataset tests, the habitat tests and the range test need columns or range polygons the table does not have; the articles on fossils, on outliers and dataset tests and on custom reference points and polygons cover them:

p <- test_plan(f)
all_tests <- p$test[p$run]
all_tests
#>  [1] "invalid"                  "zero"                    
#>  [3] "equal"                    "capital"                 
#>  [5] "centroid"                 "institution"             
#>  [7] "gbif_headquarters"        "sea"                     
#>  [9] "urban"                    "country_mismatch"        
#> [11] "duplicate"                "issue_coordinate"        
#> [13] "issue_taxon"              "issue_date"              
#> [15] "issue_rounded"            "basis_fossil"            
#> [17] "basis_living"             "establishment_introduced"
#> [19] "establishment_managed"    "outlier"                 
#> [21] "aohi"
p[!p$run, c("test", "missing")]
#>                      test        missing
#> 20              age_equal min_ma, max_ma
#> 22            age_outlier min_ma, max_ma
#> 23 spatiotemporal_outlier min_ma, max_ma
#> 24      age_range_outlier min_ma, max_ma
#> 25           dataset_ddmm     datasetKey
#> 26        dataset_rounded     datasetKey
#> 27                  range range polygons
#> 29          sea_nonmarine          genus
#> 30            land_marine          genus

The result is the input with an integer flags column and one logical column per test. A FALSE in a flag column means that the test did not flag the record. It does not mean that every test examined the record: a test that did not run has no column, and a grouped test leaves a species with too few records untested. flag_reference() returns the label of the reference data that produced the flags, with the version of every layer. It is worth storing next to any cleaned table that goes into an analysis.

flag_reference(f)
#> [1] "OccurrenceCleaner 0.2.0 (land 110; land_110 4.1.0, land_50 4.1.0, land_10 5.1.1, countries_50 5.1.1, urban_50 4.1.0, places_10 5.1.2, admin0_10 5.1.1, admin1_10 5.1.1, factbook factbook.json 144d6977b2b01ac1cbd220de754c0a005616760b, world_borders 0.3 (2008-07-30), geolocate 2026-10-11, institutions 2026-10-09, institutions_wikidata 2026-10-10, institutions_wikipedia en revision 1376233148; de revision 267938927; fr revision 240149609; nl revision 69876533; pt revision 71307420; ar revision 76691072; eo revision 9165115, institutions_geonames 2026-10-10, institutions_bgci 2026-10-10, institutions_index_herbariorum 2026-10-13, institutions_gbif_registry 2026-10-13, institutions_coordinatecleaner_list 3.0.1, aohi 2022-10-10, habitat 2026-10-06)"

summary() turns the flags into shares per test. With one rule of every test, it gives for each test the records it flags, their percentage, and the records for which it is the only flag:

s <- as.data.frame(summary(f, rules = list(all = all_tests)))
shown <- s[s$flagged > 0, c("test", "flagged", "pct", "only")]
test flagged pct only
zero 20 0.88 0
equal 20 0.88 0
capital 75 3.30 0
centroid 14 0.62 2
institution 2 0.09 1
gbif_headquarters 15 0.66 0
sea 44 1.93 1
urban 80 3.51 19
country_mismatch 79 3.47 5
duplicate 87 3.82 41
issue_coordinate 25 1.10 0
issue_taxon 20 0.88 0
issue_rounded 20 0.88 0
basis_living 20 0.88 13
establishment_managed 21 0.92 14
outlier 45 1.98 0
aohi 41 1.80 6
(any) 274 12.04

The (any) row is the rule as a whole: 274 of the 2,276 records carry at least one flag. The only column counts the records flagged by that test alone. These are the records the rule would additionally keep if that test were dropped from it.

Because the errors are labelled, a cross table of tests against the truth shows which test catches which kind of error, and whether any background record is flagged. Tests are in rows and the error groups in columns; the duplicates and the fuzzy taxon matches are left out, since one test each catches them:

show <- c("zero", "equal", "capital", "centroid", "institution", "gbif_headquarters", "sea",
          "country_mismatch", "duplicate", "issue_coordinate", "basis_living", "establishment_managed")
groups <- c("background", "capital", "zero", "gbif_hq", "swapped", "negated", "rounded",
            "living", "cultivated")
tab <- sapply(show, function(t) tapply(f[[paste0("flag_", t)]], f$truth, sum))
tab <- t(tab[groups, ])
background capital zero gbif_hq swapped negated rounded living cultivated
zero 0 0 20 0 0 0 0 0 0
equal 0 0 20 0 0 0 0 0 0
capital 0 60 0 15 0 0 0 0 0
centroid 0 9 0 0 0 0 5 0 0
institution 0 1 0 0 0 0 1 0 0
gbif_headquarters 0 0 0 15 0 0 0 0 0
sea 0 0 20 0 9 15 0 0 0
country_mismatch 2 0 20 15 25 17 0 0 0
duplicate 0 0 15 10 0 0 3 7 7
issue_coordinate 0 0 0 0 25 0 0 0 0
basis_living 0 0 0 0 0 0 0 20 0
establishment_managed 0 0 0 0 0 0 0 0 21

Most error types trip more than one test. All 25 swapped records fail country_mismatch and carry GBIF’s own PRESUMED_SWAPPED_COORDINATE warning (issue_coordinate); 9 of them also land at sea. The 15 GBIF-headquarters records are flagged by gbif_headquarters and capital (Copenhagen is within 10 km), and by country_mismatch, because the point lies just off the 1:50 million Natural Earth coastline of Denmark that the country test uses, while the 1:110 million land of the sea test still covers it. The zero coordinates fail zero, equal, sea and country_mismatch.

The negated longitudes show the limit of the spatial tests. Only 18 of 25 are caught: a French record at 1.5 E negated to 1.5 W is still in western France, on land and in the right country, and no record test can tell it from a correct one. Of the 30 records entered at whole degrees, five are caught by centroid, because they fall on the whole-degree country centroids of France (2 E, 46 N) and Germany (9 E, 51 N), and one by institution. The others are not caught: a whole-degree point in Austria is still in Austria, and GBIF’s COORDINATE_ROUNDED warning does not mark them, since it records that GBIF itself rounded a coordinate to five decimals. Low precision of this kind is read from coordinateuncertaintyinmeters when the source states it; these 30 records state none.

Of the 2,000 background records, 1,998 carry no flag in these columns. The other two are the points in Bavaria, at 13.0 E, 47.6 to 47.7 N, and country_mismatch is right about them. The duplicates among the injected groups (7 cultivated, 7 living, 10 at the GBIF headquarters) arise because those records copy the coordinates of existing records and sometimes draw the same species.

Where the flagged records are

plot() with type = "map" draws the records over the Natural Earth land, coloured by whether a rule removes them. With the CoordinateCleaner-equivalent spatial tests as the rule, the capital clusters, the point at (0, 0), the swapped records in Arabia and East Africa and the negated ones in the Atlantic stand out:

spatial <- c("zero", "equal", "capital", "centroid", "institution", "gbif_headquarters",
             "sea", "country_mismatch")
plot(f, type = "map", rules = list(spatial = spatial))

-20 0 20 40 60 0 20 40 60 rule spatial: 146 of 2,276 records removed kept removed

How often tests coincide

A record is removed by a rule when it carries any test of that rule, so the overlap between tests decides how much each extra test adds. The flag word makes the combinations easy to count: each distinct value of flags is one combination of tests.

flagged <- f[f$flags != 0L, ]
combo <- vapply(flagged$flags, function(w)
  paste(all_tests[vapply(all_tests, function(t) has_flag(w, t), logical(1))], collapse = " + "),
  character(1))
knitr::kable(head(sort(table(combo), decreasing = TRUE), 12), col.names = c("tests", "records"))
tests records
capital + urban 44
duplicate 41
urban 19
country_mismatch + issue_coordinate + outlier 16
issue_taxon + issue_rounded 15
zero + equal + sea + country_mismatch + duplicate + outlier + aohi 15
establishment_managed 14
sea + country_mismatch 14
basis_living 13
capital + gbif_headquarters + country_mismatch + duplicate 10
sea + country_mismatch + issue_coordinate + outlier 9
duplicate + basis_living 7

The most common combination is capital + urban: a capital lies inside its own urban area, so in a rule that already includes capital, adding urban removes only the city records more than 10 km from the capital point. Of the 20 zero-coordinate records, 15 carry seven tests at once: zero, equal, sea, country_mismatch, outlier, aohi (the inventory has a point at (0, 0)) and duplicate, since they share their coordinates and often their species. The swapped records lie far from the other records of their species, so outlier flags all 25 of them. Records with a single flag (duplicate, issue_rounded, establishment_managed, basis_living) are the ones a rule keeps or loses depending on whether that one test is in it.

Comparing cleaning rules

A cleaning rule is a set of tests. Five rules, from loose to strict, show the range of choices on the same flags:

  • coordinatecleaner: the CoordinateCleaner defaults of clean_coordinates() without the outlier test (invalid, zero, equal, capital, centroid, institution, GBIF headquarters, sea), the same rule as in Choosing a cleaning rule;

  • coordinatecleaner_country: coordinatecleaner plus the country check;

  • coordinatecleaner_gbif: coordinatecleaner_country plus GBIF’s coordinate and taxon warnings;

  • wild: coordinatecleaner_gbif, and also excludes living specimens and cultivated or managed individuals;

  • strict: every test, including duplicates and GBIF’s rounding note.

rules <- list(
  coordinatecleaner = c("invalid", "zero", "equal", "capital", "centroid", "institution",
                        "gbif_headquarters", "sea"))
rules$coordinatecleaner_country <- c(rules$coordinatecleaner,
                                     "country_mismatch")
rules$coordinatecleaner_gbif <- c(rules$coordinatecleaner_country,
                                  "issue_coordinate", "issue_taxon")
rules$wild <- c(rules$coordinatecleaner_gbif,
                "basis_living", "establishment_managed")
rules$strict <- all_tests
t_all <- as.data.frame(tally_flags(f, rules = rules))
ladder <- data.frame(rule = names(rules), tests = lengths(rules),
                     removed = unlist(t_all[1, paste0("removed_", names(rules))], use.names = FALSE),
                     kept = unlist(t_all[1, paste0("kept_", names(rules))], use.names = FALSE))
ladder$added <- c(ladder$removed[1], diff(ladder$removed))
rule tests removed kept added
coordinatecleaner 8 125 2151 125
coordinatecleaner_country 9 146 2130 21
coordinatecleaner_gbif 11 166 2110 20
wild 13 207 2069 41
strict 21 274 2002 67

The ladder removes 125 records under coordinatecleaner and 274 under strict. The country check adds 21, the GBIF warnings 20 and the living and cultivated records 41. strict adds the remaining 67 through duplicate (42 records, 40 of them the injected copies), urban (20) and aohi (6), some records carrying more than one; the 20 records with issue_rounded are already removed through issue_taxon.

For one rule, plot() on the summary draws a bar per test: the full bar is the share the test flags, the dark part the share for which it is the only reason the rule removes the record. A test with a long light bar and a short dark one mostly flags records that other tests in the rule remove anyway. invalid is 0 here because every example record has valid coordinates:

plot(summary(f, rules = rules["coordinatecleaner_gbif"]))

% of records 0 1 2 3 invalid institution centroid gbif_headquarters zero equal issue_taxon issue_coordinate sea capital country_mismatch 0.00 0.09 0.62 0.66 0.88 0.88 0.88 1.10 1.93 3.30 3.47 rule coordinatecleaner_gbif removes 7.3 % of 2,276 records flagged only reason

tally_flags() returns removed_<rule> and kept_<rule> for every rule, plus flagged_<test> for every test, per group. Grouped by kingdom it shows whether a rule hits one group harder than another. The share of each kingdom’s records that each rule removes, rules in rows:

t_k <- as.data.frame(tally_flags(f, by = "kingdom", rules = rules))
share <- sapply(names(rules), function(r) round(t_k[[paste0("removed_", r)]] / t_k$n, 3))
dimnames(share) <- list(t_k$kingdom, names(rules))
Animalia Fungi Plantae
coordinatecleaner 0.062 0.028 0.061
coordinatecleaner_country 0.072 0.035 0.071
coordinatecleaner_gbif 0.085 0.043 0.076
wild 0.109 0.063 0.087
strict 0.132 0.102 0.119

The same table as a figure, one line per kingdom across the rule ladder:

long <- do.call(rbind, lapply(names(rules), function(r)
  data.frame(kingdom = rownames(share), rule = r, share = share[, r])))
long$rule <- factor(long$rule, levels = names(rules))
kingdom_cols <- c(Animalia = "#B5342B", Fungi = "#2E6F9E", Plantae = "#3F7A3D")
ggplot(long, aes(rule, share, colour = kingdom, shape = kingdom, group = kingdom)) +
  geom_line(linewidth = 1) + geom_point(size = 2.5) +
  scale_colour_manual(values = kingdom_cols) +
  scale_y_continuous(labels = function(v) paste0(100 * v, " %")) +
  labs(x = NULL, y = "records removed") +
  theme_minimal(base_size = 17) +
  theme(axis.text.x = element_text(angle = 35, hjust = 1),
        panel.background = element_rect(fill = "transparent", colour = NA),
        plot.background = element_rect(fill = "transparent", colour = NA),
        legend.background = element_rect(fill = "transparent", colour = NA))

fill: #F5F6F8;' /> fill: #F5F6F8;' /> 2.5 % 5 % 7.5 % 10 % 12.5 % coordinatecleaner coordinatecleaner_country coordinatecleaner_gbif wild strict records removed fill: #F5F6F8;' /> kingdom Animalia Fungi Plantae

Record age and coordinate uncertainty

Year and stated coordinate uncertainty are not tests. They stay raw columns and are cut into bins at tally time, so the same flags support any age or precision threshold. Grouping by year bin shows where a rule removes most:

t_y <- tally_flags(f, bins = list(year = c(1900, 1950, 2000)),
                   rules = rules["coordinatecleaner_country"])
t_y$share <- round(t_y$removed_coordinatecleaner_country / t_y$n, 3)
shown <- t_y[, c("year_bin", "n", "flagged_capital", "removed_coordinatecleaner_country", "share")]
year n flagged by capital removed share
<1900 267 31 37 0.139
1900-1950 605 34 53 0.088
1950-2000 846 7 38 0.045
>=2000 558 3 18 0.032

The rule removes 13.9 % of the records before 1900 and 3.2 % of those from 2000 on, because the capital-georeferenced herbarium sheets were simulated with years before 1950. Uncertainty bins work the same way; records without a stated uncertainty form the group "missing":

t_u <- tally_flags(f, bins = list(coordinateuncertaintyinmeters = c(100, 1000)),
                   rules = rules["coordinatecleaner_country"])
shown <- t_u[, c("coordinateuncertaintyinmeters_bin", "n",
                 "removed_coordinatecleaner_country", "kept_coordinatecleaner_country")]
uncertainty (m) n removed kept
<100 775 59 716
100-1000 370 23 347
>=1000 736 36 700
missing 395 28 367

A threshold on uncertainty is then a filter on the raw column next to the rule, for example keeping records with a stated uncertainty of at most 1 km:

kept <- keep_unflagged(f, rules$coordinatecleaner_country)
kept <- kept[which(kept$coordinateuncertaintyinmeters <= 1000), ]
c(records = nrow(f), kept = nrow(kept))
#> records    kept 
#>    2276    1423

The cleaned table

Applying a rule is one call to keep_unflagged(). The flags stay attached, so a later analysis can still tell which tests the kept records passed and can tighten the rule without rerunning anything:

clean <- keep_unflagged(f, rules$wild)
nrow(clean)
#> [1] 2069
table(clean$truth)
#> 
#> background  duplicate    negated    rounded 
#>       1998         40          7         24

The wild rule keeps 1,998 of the 2,000 background records and removes every injected error except the 40 duplicates, which it does not target, and 24 of the 30 whole-degree records and the 7 negated records, which no record test can see. The 20 records with a fuzzy taxon match are removed through issue_taxon, although their coordinates are fine.

Practical guidance

  • Run every test the columns allow. flag_occurrences() does so without tests, and test_plan() shows what did not run and why. The pass over the records is the expensive step; rules are a bit-mask query afterwards. On an AMD Ryzen 7 5700G the nine CoordinateCleaner tests that need only coordinates and a species took 1.9 s for one million records in memory (see the timings in Compatibility with CoordinateCleaner).

  • country_mismatch needs a country code and flags records without one. A record with no code, or one that falls at sea, is flagged, as in CoordinateCleaner. In a GBIF table where many records lack countrycode, that test alone can flag a large share; the tally per test shows it before it goes into a rule.

  • sea depends on the land scale. The default 1:110 million land is coarse: in the example the GBIF headquarters are on land at that scale and at sea on the 1:50 million coast. For coastal or island taxa, land_scale = 50 or 10 changes the result for records within a few kilometres of the coast; Reference data shows the size of the difference.

  • duplicate depends on what counts as the same record. By default it is the same coordinates and the same species value. Adding duplicate_by = "eventdate" keeps repeat visits apart; duplicate_cell = 0.1 treats records in the same 0.1-degree cell as duplicates, which suits a grid-based analysis. On streamed input the test compares records across all input files, as on a data frame.

  • GBIF issue tests are GBIF’s own processing results. They catch errors that the spatial tests cannot see (a swapped coordinate that still lands on land, a fuzzy taxon match). issue_rounded is a precision note rather than an error: GBIF sets COORDINATE_ROUNDED when it rounds a coordinate given with more than five decimals, which moves the point by at most about 1 m.

  • Do not cut at a threshold during flagging. Year, uncertainty and any other numeric field belong in bins or in a subset after the rule, so one flag pass serves every threshold an analysis or a reviewer asks for.