Skip to contents

OccurrenceCleaner extends the record-cleaning tests developed in CoordinateCleaner (Zizka et al. 2019) to tables containing millions to billions of records. It processes large datasets in chunks from disk, including snapshots of the Global Biodiversity Information Facility (GBIF), and adds tests based on GBIF metadata and genus habitats.

Each test stores a flag alongside the original record. You can then inspect what was flagged, compare cleaning rules, and choose which records to exclude without rerunning the tests. This makes it practical to explore how cleaning choices affect an entire dataset.

CoordinateCleaner provides the underlying test definitions, default distances, and methods for identifying coordinate errors, outliers, dataset-wide patterns, and fossil-age problems. It underwent rOpenSci software peer review in 2018 and remains the methodological reference to cite for these tests. OccurrenceCleaner was first released in 2026; its agreement with CoordinateCleaner is checked by its test suite, and it has yet to undergo independent software review.

This article shows where the two packages agree, under which settings, and where their results change and why. It maps each CoordinateCleaner function to its counterpart, describes how agreement is tested and how to check it on your own records, and gives timings for the tests both packages run. The chunks that call CoordinateCleaner run only when it is installed; it is a suggested package, not a dependency. Installation is covered in Get started.

Running both packages on the same records

Both packages can run several tests in one call and return every record, with one logical column per test. The columns mean opposite things. In CoordinateCleaner, TRUE marks a record that passed the test. In OccurrenceCleaner, TRUE marks a record that failed it. Comparing the two outputs therefore means inverting one of them.

The example data are 4,000 records placed within 30 km of five capitals, so that some of them lie inside the 10 km buffer of the capital test. Fifty of them are then overwritten with errors:

  • 20 are placed at (0, 0), which the zero and equal tests flag in both packages.

  • 20 are given a latitude equal to their longitude, for the equal test.

  • 10 are given coordinates within 0.3 degrees of zero, where the zero tests of the two packages differ.

library(OccurrenceCleaner)

set.seed(3)
caps <- cbind(lon = c(16.37, 2.35, -3.70, 13.40, 69.17), lat = c(48.21, 48.86, 40.42, 52.52, 34.53))
k <- sample(5, 4000, TRUE)
p <- geosphere::destPoint(caps[k, ], b = runif(4000, 0, 360), d = 30000 * sqrt(runif(4000)))
x <- data.frame(species = sample(c("a", "b"), 4000, TRUE),
                decimalLongitude = round(p[, 1], 4), decimalLatitude = round(p[, 2], 4))
x[1:20, 2:3] <- 0
x[21:40, 3] <- x[21:40, 2]
x[41:50, 2:3] <- round(runif(20, -0.3, 0.3), 4)

Run seven tests in both packages. reference = "CoordinateCleaner" reads that package’s own reference points at run time, so both test against the same points:

cc <- CoordinateCleaner::clean_coordinates(
  x, lon = "decimalLongitude", lat = "decimalLatitude", species = "species",
  tests = c("capitals", "centroids", "equal", "gbif", "institutions", "zeros", "duplicates"),
  value = "spatialvalid", verbose = FALSE)
oc <- flag_occurrences(x, tests = c("invalid", "zero", "equal", "capital", "centroid",
                                    "institution", "gbif_headquarters", "duplicate"),
                       reference = "CoordinateCleaner")

Per test, the number of records each package flags and the share of records with the same verdict:

pairs <- c(.zer = "zero", .equ = "equal", .cap = "capital", .cen = "centroid",
           .inst = "institution", .gbf = "gbif_headquarters", .dpl = "duplicate")
agree <- vapply(names(pairs), function(cn)
  mean((!cc[[cn]]) == oc[[paste0("flag_", pairs[[cn]])]], na.rm = TRUE), numeric(1))
knitr::kable(data.frame(CoordinateCleaner = names(pairs), OccurrenceCleaner = pairs,
                        flagged_cc = vapply(names(pairs), function(cn) sum(!cc[[cn]], na.rm = TRUE), 1),
                        flagged_oc = vapply(pairs, function(t) sum(oc[[paste0("flag_", t)]]), 1),
                        agreement = round(agree, 5)), row.names = FALSE)
CoordinateCleaner OccurrenceCleaner flagged_cc flagged_oc agreement
.zer zero 20 30 0.99750
.equ equal 40 40 1.00000
.cap capital 435 436 0.99975
.cen centroid 29 29 1.00000
.inst institution 0 0 1.00000
.gbf gbif_headquarters 0 0 1.00000
.dpl duplicate 18 18 1.00000

Five of the seven tests give the same verdict on every record. zero differs for the 10 records near but not at (0, 0), and capital for records at the edge of the buffer; Where the results differ explains both.

Removing the flagged records

CoordinateCleaner’s single-test functions return the records that pass by default (value = "clean"), so a chain of them removes records as it goes:

library(CoordinateCleaner)
clean <- x |>
  cc_val() |>
  cc_equ() |>
  cc_zero() |>
  cc_cap() |>
  cc_cen() |>
  cc_inst() |>
  cc_gbif() |>
  cc_sea()

Each test here has a cleaning function, clean_<test>(), which runs the test and returns the records it does not flag:

Both chains run tests in sequence, with each test receiving only the records kept by the previous steps. For the record-level tests shown here, this gives the same result as testing the full input. However, the differences explained in Where the results differ can affect which records are kept.

Each cleaning function accepts its test settings as arguments. For example, clean_capital(x, buffer = 5000) sets the buffer to 5000 metres. The function’s help page, ?clean_capital, documents the corresponding test.

To keep all records and decide later which flags should lead to exclusion, use flag_occurrences() first, then keep_unflagged():

f <- flag_occurrences(x, tests = c("invalid", "equal", "zero", "capital", "centroid",
                                   "institution", "gbif_headquarters", "sea"))
clean <- keep_unflagged(f)

keep_unflagged() keeps the records that no test that ran has flagged. Its tests argument sets which flags exclude a record, so a different rule needs no second run. This rule keeps the records near capitals for inspection:

near_capital <- keep_unflagged(f, tests = c("invalid", "equal", "zero", "centroid",
                                            "institution", "gbif_headquarters", "sea"))

A grouped test (duplicate, outlier, the fossil outlier tests, the dataset tests) compares a record with the other records of its group. In a chain its result depends on which records the earlier steps removed, while flag_occurrences() runs it on the full input.

How agreement is tested

The package’s test suite runs CoordinateCleaner 3.0.1 and flag_occurrences() on sampled records for every test both packages share, and requires identical flags except in the cases described under Where the results differ. A second run of the suite uses the development version of CoordinateCleaner from GitHub, so a change there shows up as a failing test. The duplicate test is compared both on a data frame and on a table streamed from disk, where it still looks at the whole table, as CoordinateCleaner does.

These tests show agreement on the sampled records. They do not show that either package’s flags are right for a given analysis; that depends on the data and the question, as CoordinateCleaner’s own tutorial points out.

Checking agreement on your own records

To check agreement on your own data, run both packages on a sample of it as in the chunks above, with the same tests and the same reference data:

  • reference = "CoordinateCleaner", so both use CoordinateCleaner’s reference points.

  • land_scale = 50 when the sea test is included, since clean_coordinates() uses the 1:50 million land and flag_occurrences() defaults to 1:110 million.

  • The buffers you set in CoordinateCleaner (capitals_rad, …) passed in buffers, mapped in Arguments and output.

The outlier test, the fossil outlier tests and the two dataset tests compare a record with the other records of its species, taxon or dataset, so their flags depend on the rest of the input. Compare them on the same input in both packages; adding records of a species can change whether a record of it is an outlier. A disagreement outside the cases described below is a bug in OccurrenceCleaner and can be reported on its issue tracker.

Function mapping

CoordinateCleaner has one function per test, cc_cap() for capitals, cc_sea() for the sea and so on, and three functions that run several tests at once: clean_coordinates() and clean_fossils(), which return one column per test, and clean_dataset(), which returns a table per dataset. In OccurrenceCleaner, flag_occurrences() runs any selected combination of tests. A test is chosen by name, its buffer is set in buffers and its other options in settings.

The first table gives each test’s name in the three places it appears: CoordinateCleaner’s test function, the tests argument of CoordinateCleaner’s combined functions, and the tests argument of flag_occurrences(). The second table maps the arguments and the forms of output, and the third the functions around the tests.

A test’s cleaning function, clean_capital() for the capital test and so on, removes the flagged records as CoordinateCleaner’s test functions do by default; Removing the flagged records shows both chains.

The packages share no function names, so both can be attached without one hiding the other’s functions.

Tests

Test function tests in clean_coordinates() and others tests in flag_occurrences()
cc_val() always runs; validity in clean_fossils() invalid
cc_zero() zeros zero
cc_equ() equal equal
cc_cap() capitals capital
cc_cen() centroids centroid
cc_inst() institutions institution
cc_gbif() gbif gbif_headquarters
cc_sea() seas sea
cc_urb() urban urban
cc_coun() countries country_mismatch
cc_dupl() duplicates duplicate
cc_outl() outliers outlier
cc_iucn() range range
cc_aohi() aohi aohi
cf_equal() agesequal age_equal
cf_age() not in clean_fossils() age_outlier
cf_outl() spatiotemp spatiotemporal_outlier
cf_range(method = "quantile") or "mad" temprange age_range_outlier
cd_ddmm() ddmm in clean_dataset() dataset_ddmm
cd_round() periodicity in clean_dataset() dataset_rounded

clean_coordinates() always checks for missing or out-of-range coordinates first, and if any coordinate fails, it stops and returns that result alone. Here that check is the invalid test, and the other tests still run on the records that pass it.

Arguments and output

Setting CoordinateCleaner OccurrenceCleaner
Running several tests clean_coordinates(), clean_fossils(), clean_dataset() flag_occurrences(x, tests = ...)
Buffer around the reference points capitals_rad, centroids_rad, inst_rad, … buffers = list(capital = ..., centroid = ..., institution = ...)
Options of the outlier and dataset tests method, mltpl, size_thresh, replicates, … settings = list(outlier = list(method = ...), ...)
Record counts for the sampling correction of the outlier test cc_outl(sampling_thresh = ...), counts queried from GBIF the same option in settings, with the counts given as a table in gazetteers = list(sampling = ...)
Your own reference points ref of each test function; capitals_ref, seas_ref, … in clean_coordinates() gazetteers = list(capital = ..., sea = ...)
Scale of the land polygons in the sea test seas_scale (default 50) land_scale (default 110, as cc_sea())
Columns that define a duplicate cc_dupl(additions = ...) duplicate_by
Limit on the width of a fossil’s age range cf_range(method = "time", max_range = ...) no test; tally_flags(bins = ...) on the range width
Removing the flagged records value = "clean" clean_capital() and the other cleaning functions, or keep_unflagged(f, tests = rule) after flagging
Keeping every record with its flags value = "flagged" or "spatialvalid" the flag columns; as_spatialvalid(f) for the spatialvalid layout

Functions around the tests

The functions of CoordinateCleaner 3.0.1 besides the tests correspond to these:

CoordinateCleaner what it does OccurrenceCleaner
cc_outl(sampling_thresh = …) keeps outliers only in well-sampled countries, with record counts queried online the same rule, with the record counts given as a table (gazetteers = list(sampling = …))
write_pyrate() writes fossil occurrences as PyRate input files export_pyrate(), the same file layout
plot() maps flagged records plot(f, type = “map”)
summary() counts flagged records per test summary(f)
is.spatialvalid() checks the class of a clean_coordinates() result the occ_flags class

CoordinateCleaner’s outlier test has an optional sampling correction, which keeps outliers flagged only in well-sampled countries. With the correction on, it downloads the record count of each country from GBIF at run time, so the same call can return different results as GBIF grows. OccurrenceCleaner takes the counts as a table and writes the table’s checksum to the reference label.

The PyRate export is called export_pyrate(). CoordinateCleaner’s is write_pyrate(), and with different names both stay usable when the two packages are attached.

Code written for CoordinateCleaner’s output runs on a converted result, made with as_spatialvalid(). The converted result has the record columns, one column per test, the summary column and CoordinateCleaner’s class. The test columns carry CoordinateCleaner’s names and its sign, TRUE for a pass. CoordinateCleaner’s summary and plot methods run on the result unchanged, and so do filters on the summary column. For the validity, equal and duplicate tests, the package tests compare each column of a converted result with CoordinateCleaner’s own output.

sv <- as_spatialvalid(oc)
head(sv[, c(".zer", ".equ", ".cap", ".dpl", ".summary")])
#>    .zer  .equ .cap  .dpl .summary
#> 1 FALSE FALSE TRUE  TRUE    FALSE
#> 2 FALSE FALSE TRUE  TRUE    FALSE
#> 3 FALSE FALSE TRUE FALSE    FALSE
#> 4 FALSE FALSE TRUE FALSE    FALSE
#> 5 FALSE FALSE TRUE FALSE    FALSE
#> 6 FALSE FALSE TRUE FALSE    FALSE
summary(sv)
#>     .val     .zer     .equ     .cap     .cen    .inst     .gbf     .dpl 
#>        0       30       40      436       29        0        0       18 
#> .summary 
#>      491

Where the results differ

Most differences reflect design choices in OccurrenceCleaner: how buffers are measured, how invalid coordinates and random draws are handled, and which reference data are used by default. Four other differences arise from reported issues in CoordinateCleaner 3.0.1 (#114, #99, #110, #66), where tests either behave differently from their documentation or fail with an error. These issues may be resolved in later versions.

Design differences

Buffer circles

The packages use different methods to test whether a record falls within a buffer:

  • CoordinateCleaner approximates the buffer with a polygon.

  • OccurrenceCleaner calculates the distance to the reference location directly.

Because the polygon’s edges lie slightly inside the circle, records near the boundary can give different results.

In this example, two records are placed 9,995 metres from the same reference point in Kabul, just inside a 10,000-metre buffer. One is aligned with a polygon vertex, while the other lies halfway between two vertices. Both are within the distance limit, but the polygon may exclude the second record.

e <- new.env()
utils::data("countryref", package = "CoordinateCleaner", envir = e)

kabul <- unlist(
  e$countryref[
    e$countryref$iso3 == "AFG",
    c("capital.lon", "capital.lat")
  ][1, ]
)

p <- geosphere::destPoint(kabul, b = c(0, 2.5), d = 9995)

y <- data.frame(
  species = "a",
  decimalLongitude = p[, 1],
  decimalLatitude = p[, 2]
)

data.frame(
  bearing = c(0, 2.5),
  distance_m = 9995,
  cc_flagged = !CoordinateCleaner::cc_cap(
    y, value = "flagged", verbose = FALSE
  ),
  oc_flagged = flag_occurrences(
    y,
    tests = "capital",
    reference = "CoordinateCleaner"
  )$flag_capital
)
#>   bearing distance_m cc_flagged oc_flagged
#> 1     0.0       9995       TRUE       TRUE
#> 2     2.5       9995      FALSE       TRUE

Both packages flag the first record. Only OccurrenceCleaner flags the second, which falls just outside CoordinateCleaner’s polygon despite being within the 10,000-metre buffer.

Because both packages use the same reference point and buffer radius, the difference comes entirely from how they define the boundary.

Records without valid coordinates

A record with a missing or out-of-range coordinate is treated differently by each package. In CoordinateCleaner the outcome depends on the test: the zero and equal tests return NA for it, the capital test treats it as clean, and the duplicate test flags the second of two such records. OccurrenceCleaner gives it the invalid flag and skips the other spatial tests. Including invalid in a cleaning rule removes these records; leaving it out keeps them, with no spatial flag.

Random age draws

CoordinateCleaner’s fossil tests draw one age uniformly from each record’s age range, run the test five times and keep the majority vote. The draws come from the session’s random stream in input order, so the flags change with the seed and with the row order. OccurrenceCleaner follows the same procedure but seeds a stream per taxon and orders the draws by record content, so the same records give the same flags whatever their order and however they are split across files. The seed is a setting of the age outlier test (seed under age_outlier in settings). When every age range has zero width nothing is drawn and the two packages give identical flags. When ages have to be drawn, the flags of a single run can differ record by record between the packages, even though the procedure is the same; the test suite checks only the zero-width case.

Records without a dataset key

Records without a dataset key are left unflagged by both dataset tests here; CoordinateCleaner’s cd_ddmm() flags them.

Country codes

CoordinateCleaner compares the record’s country code with the ISO alpha-3 code of the polygon the record falls in. GBIF stores country codes as alpha-2, so a GBIF table has to be converted before the test runs. OccurrenceCleaner accepts alpha-2, alpha-3 and Natural Earth’s own three-letter code, so no conversion is needed. For records whose code is alpha-3 and whose country is in Natural Earth, the two tests agree, apart from the three countries described under Records in France, Norway and Kosovo.

Gazetteers

By default the gazetteers are rebuilt from the sources CoordinateCleaner’s tables were originally derived from. They hold the same kinds of points but not the same coordinates; the Reference data article lists the sources and measures the differences. Points from sources CoordinateCleaner does not use, such as Wikidata institutions and Natural Earth capitals, are marked as extensions and included by default; Extension points shows how to leave them out. Setting reference = "CoordinateCleaner" reads CoordinateCleaner’s own tables from your installation instead, and then reproduces its flags up to the differences listed on this page.

Behaviour of CoordinateCleaner 3.0.1

The zero buffer

Both packages flag records whose longitude or latitude is exactly zero, and both also flag a buffer around (0, 0). The buffer differs. CoordinateCleaner 3.0.1 applies a radius of 0.5 metres, while its documentation specifies 0.5 degrees; OccurrenceCleaner uses the documented 0.5 degrees, so it flags a larger area around the origin. Setting the buffer to zero (buffers = list(zero = 0)) turns the buffer off and keeps only the exact-zero check.

z <- data.frame(species = "a", decimalLongitude = c(0, 0.3, 0.001), decimalLatitude = c(12, 0.3, 0.001))
data.frame(z[, 2:3],
           cc_flagged = !CoordinateCleaner::cc_zero(z, value = "flagged", verbose = FALSE),
           oc_flagged = flag_occurrences(z, tests = "zero")$flag_zero,
           oc_zero_buffer_0 = flag_occurrences(z, tests = "zero", buffers = list(zero = 0))$flag_zero)
#>   decimalLongitude decimalLatitude cc_flagged oc_flagged oc_zero_buffer_0
#> 1            0.000          12.000       TRUE       TRUE             TRUE
#> 2            0.300           0.300      FALSE       TRUE            FALSE
#> 3            0.001           0.001      FALSE       TRUE            FALSE

Records in France, Norway and Kosovo

CoordinateCleaner 3.0.1 flags every record in France, Norway and Kosovo as a country mismatch. Natural Earth gives these three countries no ISO code in the column CoordinateCleaner reads (the value is -99), so no record can match. OccurrenceCleaner reads Natural Earth’s alternative code columns (ISO_A2_EH, ISO_A3_EH), which are filled for all three.

Outliers of large species

For species of 10,000 records or more, and for every species when thinning = TRUE, CoordinateCleaner’s outlier test switches to a raster approximation: it counts records per cell of a 0.5-degree grid, computes the distance statistics between cells, and selects the outlier cells. In CoordinateCleaner 3.0.1 this path flags no records. Twenty records 1,000 km from the other 11,980 of their species show it:

set.seed(1)
n <- 12000
r <- data.frame(species = "a", decimalLongitude = c(runif(n - 20, 10, 20), runif(20, 120, 130)),
                decimalLatitude = c(runif(n - 20, 40, 50), runif(20, -30, -20)))
c(cc_flagged = sum(!CoordinateCleaner::cc_outl(r, value = "flagged", verbose = FALSE)),
  oc_flagged = sum(flag_occurrences(r, tests = "outlier")$flag_outlier))
#> cc_flagged oc_flagged 
#>          0         20

OccurrenceCleaner’s outlier test uses the same grid, weights and statistics and flags the records of the outlier cells. Below 10,000 records, where CoordinateCleaner computes exact distances, the two give identical flags.

Small datasets

CoordinateCleaner’s cd_round() skips datasets with fewer distinct coordinates than its minimum, but in version 3.0.1 an input that contains such a dataset next to larger ones stops with an error, so no dataset is tested. OccurrenceCleaner leaves the small dataset untested and tests the others.

Timings

The timings measure how long each package takes to run the tests both share, on synthetic records on one machine. They do not measure agreement, which the test suite above covers, or how either package performs on real occurrence data with its clustering and duplicates.

The tables give the elapsed time in seconds of one call per test, on one million and on ten million records; lower values mean faster runs. In both packages the call used CoordinateCleaner’s reference points. The records were placed at random, a tenth of them within 30 km of a capital.

Each time is the fastest of three runs. A CoordinateCleaner test whose single run took longer than 200 s was run once, which applies to the capital and centroid tests at ten million records. Each timing covers the whole call, including reading the reference points; the land polygons of both packages were loaded before timing started. The timer resolves 0.01 s.

The machine was an AMD Ryzen 7 5700G with 64 GB of memory, running Windows 11 and R 4.6.1, with one process, OccurrenceCleaner 0.2.0 and CoordinateCleaner 3.0.1.

One million records

Test OccurrenceCleaner (s) CoordinateCleaner (s)
capital 0.14 24.64
centroid 0.16 33.80
institution 0.15 0.92
gbif_headquarters 0.08 0.58
sea 0.06 5.62
zero 0.05 0.59
equal 0.03 < 0.01
invalid 0.02 4.72
duplicate 1.12 1.17

Ten million records

Test OccurrenceCleaner (s) CoordinateCleaner (s)
capital 1.44 238.68
centroid 1.63 245.39
institution 1.68 7.38
gbif_headquarters 0.80 6.35
sea 0.66 58.43
zero 0.47 6.50
equal 0.37 0.06
invalid 0.31 43.33
duplicate 12.07 11.15

The largest differences are in the capital and centroid tests: at ten million records they take 1.44 s and 1.63 s here, and 238.68 s and 245.39 s in CoordinateCleaner. CoordinateCleaner is faster on the equal test (0.06 s against 0.37 s) and on the duplicate test (11.15 s against 12.07 s).

For the proximity tests, CoordinateCleaner tests the records against a buffer polygon around the reference points, and this package looks up each record’s nearest reference point in a kd-tree.

The shared tests in one call

With its own reference data, flag_occurrences() runs the nine CoordinateCleaner tests that need only coordinates and a species (invalid, zero, equal, capital, centroid, institution, gbif_headquarters, sea and duplicate) in one call in 1.91 s on one million records and 19.45 s on ten million. The benchmark times this call for OccurrenceCleaner only.

Large tables on disk shows how a table on disk is read in chunks. The benchmark script is bench/benchmark.R in the source repository, and its results are in bench/results/.

When is CoordinateCleaner the better choice?

CoordinateCleaner has been in use since its first CRAN release in 2017, went through rOpenSci review, and its tests have been applied and cited in published work. OccurrenceCleaner’s agreement with it rests on the test suite described above, on the records that suite samples. CoordinateCleaner may be the better choice when:

  • Your data fits in memory and runs through CoordinateCleaner in an acceptable time, and your workflow and its methods section are already built on it.

  • You want cc_outl(sampling_thresh) to query GBIF’s current record counts at run time rather than supply them as a table.

  • Your cleaning uses only equal and duplicate, where CoordinateCleaner is as fast or faster in the timings above.

References

Zizka A, Silvestro D, Andermann T, Azevedo J, Duarte Ritter C, Edler D, Farooq H, Herdean A, Ariza M, Scharn R, Svanteson S, Wengström N, Zizka V, Antonelli A (2019). CoordinateCleaner: standardized cleaning of occurrence records from biological collection databases. Methods in Ecology and Evolution 10:744-751. doi:10.1111/2041-210X.13152