The integrated database is published as parquet files on a public bucket, and every way in — R, Python, a SQL shell, a browser tab — reads those same bytes through the same catalog. There is no server to be slow, no credentials to request and nothing to download whole: DuckDB reads only the columns and row groups a query touches, over HTTPS. This chapter is the one guide to both paths CalCOFI.io offers, the public release and the team’s working store:
Table 2.1: The two ways into CalCOFI data. Everything else on this page is the left-hand column.
the public release
the working store
what
every dataset, integrated, frozen per version, cited by DOI
the CTD team’s multi-user PostgreSQL: originals verbatim, a flag ledger beside them — built and running, not yet the team’s practice (CTD QA/QC)
continuously, once adopted; accepted flags would reach the next release through a nightly snapshot, never a live read
Table 2.1 is the whole choice: unless you are on the CTD team, the public release is the only path you need.
2.1 Where the data lives
A release is a versioned folder of sidecars — the record of what it is — and its tables are parquet objects the release’s catalog.json points at:
gs://calcofi-db/ducklake/
├── releases/
│ ├── latest.txt # the promoted version
│ ├── versions.json # every version: date, tables, rows, DOI; consolidated / retired
│ ├── RELEASES.md # what changed between versions and why, newest first
│ └── {version}/
│ ├── catalog.json # every table, its rows and its objects[] — where the bytes live
│ ├── metadata.json # table and column descriptions, units, datasets, measurement types
│ ├── relationships.json # primary and foreign keys ┐ Keys and integrity
│ ├── integrity.json # the keys, measured ┘
│ ├── datasets.json # one record per dataset — what every portal reads
│ ├── eml/, stac/, coverage.json, …
│ └── RELEASE_NOTES.md
└── tables/ # the objects, content-addressed and shared between versions
├── {table}/{content_hash}/{table}.parquet
└── {table}/{col}={value}/{content_hash}/data_0.parquet
The public HTTPS root is https://storage.googleapis.com/calcofi-db/; every path in a catalog is relative to it (https://storage.calcofi.io/calcofi-db/ serves the same bucket and redirects a legacy per-release URL to its object where one exists). The promoted version is releases/latest.txt; what a version is, which are kept and how to pin one are in Releases.
Resolve a table through the catalog; never build a path by hand. Each table’s objects[] lists one parquet object for a single-file table and one per partition for a partitioned one, each with its bucket-relative path, bytes, sha256 and since. The packages’ resolvers — calcofi4r::cc_release_sources(), calcofi4py.release_sources() — turn a catalog entry into URLs for any version, and the browser example below does it in six lines of JavaScript. A typed releases/{version}/parquet/… path works only while that version’s copy is kept, and the objects it points at may be shared, moved or thinned; the catalog is the contract.
2.2 What you are reading
Two tables hold the observations, and one view unites them:
obs_bio — every biological occurrence headline: a taxon (taxon_key), its life stage, the measurement (measurement_type, value), the gear and effort of its own sampling event (tow_type, std_haul_factor, prop_sorted, volume_sampled_m3) and the two standardized densities (density_per_10m2, density_per_1000m3). One object; one taxon question is one file.
obs_env — every environmental headline: one measured scalar per row, one object per measurement_type, so one variable is one small fetch — what a browser needs.
obs — the two under one name, realm = bio or env and value as measurement_value, as a view the catalog carries (views.obs). cc_get_db() in R and Python and the query site create it, so FROM obs keeps working; its own objects are deprecated and drop in the next release.
Every observation points at its sampling event in sample (sample_key; parent_sample_key walks net → tow → site and bottle → cast) and carries its provenance and place — dataset_key, cruise_key, grid_key, position, time, hex_id — so a rollup needs no join. Event-level effort such as std_haul_factor is in sample_measurement; sub-occurrence detail (length bins, stages) in obs_attribute; the references are cruise, ship, grid, taxon, dataset, measurement_type, spatial. The database describes each; Keys and integrity says how they join.
Table 2.2: The tables of v2026.09.11 as the catalog lays them out. A partitioned table reads as an explicit list of objects (with hive_partitioning = true to recover the partition column); a twin is one whole-table object a browser reads instead of the list.
table
tier
rows
objects
partitioned by
twin
climatology
core
736,916
76
measurement_type
cruise
core
842
1
dataset
core
16
1
dataset_taxon
core
1,917
1
grid
core
218
1
lookup
core
26
1
measurement_type
core
200
1
obs_attribute
core
458,184
1
obs_bio
core
1,258,665
1
obs_env
core
29,838,093
94
measurement_type
region
core
4
1
sample
core
1,469,151
1
sample_measurement
core
589,603
1
sample_spatial
core
929,632
1
ship
core
49
1
spatial
core
13,206
1
spatial_attribute
core
148,461
1
taxon
core
2,614
1
taxon_group
core
441
1
obs
deprecated
31,096,758
16
dataset_key
yes
obs_ctd_full
supplemental
275,231,999
134
cruise_key
obs_mets_full
supplemental
19,926,523
49
cruise_key
sample_root
supplemental
421,450
1
Table 2.2 is the whole inventory, tier by tier. A supplemental table (obs_ctd_full, the full-resolution CTD scans; obs_mets_full; the sample_root join twin) is hosted and catalogued but left out of cc_get_db()’s default set — obs_ctd_full alone is ~270 M rows. Ask for it (supplemental = TRUE) and filter early.
2.3 From R
calcofi4r registers every release table as a view over the remote parquet — resolving each through the catalog, handling httpfs, the partitioned tables, the obs view and a content-addressed local cache, so a table unchanged between releases is fetched once:
# remotes::install_github("calcofi/calcofi4r")library(calcofi4r)library(dplyr)con <-cc_get_db() # the promoted release, every table as a viewcon <-cc_get_db("v2026.08.25") # pinned; a retired version errors, naming the replacementcon <-cc_get_db(supplemental =TRUE) # + obs_ctd_full, obs_mets_full, sample_rootDBI::dbListTables(con)tbl(con, "taxon") |>filter(scientific_name =="Sardinops sagax") # lazy dbplyrcc_query("SELECT dataset_key, count(*) AS n FROM obs_env GROUP BY 1 ORDER BY 2 DESC")cc_list_versions() # versions.jsoncc_release_notes() # this release's notescc_describe_table("sample") # columns, units, descriptions from metadata.json
TipIf that install stops at s2
calcofi4r needs sf, sf needs s2, and s2 is C++. CRAN has no macOS arm64 binaries for R 4.6 yet, so on Apple Silicon it compiles, and the compile needs cmake:
brew install cmake
brew install abseil will not do instead, though s2’s own failure message recommends it: its configure defaults S2_FORCE_BUNDLED_ABSEIL=true, always builds its vendored Abseil with cmake, and never asks pkg-config about a system copy.
To avoid the compile, take prebuilt binaries from Posit Package Manager — in ~/.Rprofile:
# pip install "calcofi4py @ git+https://github.com/CalCOFI/calcofi4py"import calcofi4py as cccon = cc.cc_get_db() # the promoted release; cc_get_db("v2026.08.25") to pindf = con.sql("SELECT * FROM taxon WHERE common_name ILIKE '%sardine%'").df()cc.cc_query("SELECT count(*) FROM obs_bio").fetchone()catalog = cc.cc_catalog("latest")src = cc.release_sources(catalog, "obs_env") # {"urls", "hive", "canonical", "hashes", "local_paths", "single_file"}cc.read_parquet_sql(src) # the read_parquet(...) fragment for any DuckDB
2.5 From SQL, anywhere
Any DuckDB — the command-line shell, a notebook, another language’s binding — reads the release after INSTALL httpfs; LOAD httpfs; (and spatial for ST_* functions). Take the table’s objects from the catalog and hand them to read_parquet(); a partitioned table is an explicit list with hive_partitioning = true, and that runs in every DuckDB, the browser included:
INSTALL httpfs; LOAD httpfs;-- one object: a single-file table (the path is catalog.json ▸ tables[name = 'taxon'] ▸ objects[0].path)SELECT taxon_key, scientific_name, common_name, worms_id, rankFROM read_parquet('https://storage.googleapis.com/calcofi-db/ducklake/tables/taxon/{content_hash}/taxon.parquet')WHERE scientific_name ='Sardinops sagax';-- many objects: a partitioned table, one URL per partition, in catalog orderSELECT measurement_type, count(*) AS nFROM read_parquet(['https://storage.googleapis.com/calcofi-db/ducklake/tables/obs_env/measurement_type=temperature/{hash}/data_0.parquet','https://storage.googleapis.com/calcofi-db/ducklake/tables/obs_env/measurement_type=salinity/{hash}/data_0.parquet' ], hive_partitioning =true)GROUPBY1;
Because the partition column is in the path, a filter on it lets DuckDB prune whole objects: a one-variable query over obs_env opens one file. Releases before v2026.09 have no objects[]; their partitioned tables are directories that plain HTTPS cannot glob, so they are read through an anonymous s3-style glob (SET s3_endpoint = 'storage.googleapis.com'; SET s3_url_style = 'path'; then read_parquet('s3://calcofi-db/…/obs/**/*.parquet', hive_partitioning = true)) — the resolvers return that form for those versions automatically.
2.6 From your browser
DuckDB compiles to WebAssembly, so the same engine runs in a browser tab with nothing installed. Three ready-made ways in:
the Explorer — six lenses over the whole database, every view a URL, every download with its SQL (Explore);
calcofi.io/db-query — a library of queries to start from, including the three biology-to-environment matches below, and a free SQL shell;
To embed DuckDB in your own page, the boilerplate is short: load the bundle, instantiate, resolve a table through the catalog:
<script type="module">import*as duckdb from"https://cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm@1.29.0/+esm";const bundle =await duckdb.selectBundle(duckdb.getJsDelivrBundles());const db =new duckdb.AsyncDuckDB(new duckdb.ConsoleLogger(),await duckdb.createWorker(bundle.mainWorker));await db.instantiate(bundle.mainModule, bundle.pthreadWorker);const conn =await db.connect();await conn.query("INSTALL httpfs; LOAD httpfs;");constroot="https://storage.googleapis.com/calcofi-db";const version = (await (awaitfetch(`${root}/ducklake/releases/latest.txt`)).text()).trim();const catalog =await (awaitfetch(`${root}/ducklake/releases/${version}/catalog.json`)).json();// a table's objects → a read_parquet() fragment. A partitioned table is a list (hive_partitioning);// if it also publishes a whole-table twin (the object WITHOUT partition_by), a browser reads the// twin instead of the list — never both, which would count every row twice.const readParquet = (name) => {const t = catalog.tables.find(t => t.name=== name);const twin = t.partitioned? t.objects.find(o =>!o.partition_by) :null;if (twin) return`read_parquet('${root}/${twin.path}')`;const urls = t.objects.map(o =>`'${root}/${o.path}'`).join(", ");return`read_parquet([${urls}]${t.partitioned?", hive_partitioning = true":""})`; };const result =await conn.query(` SELECT scientific_name, common_name, worms_id FROM ${readParquet("taxon")} WHERE common_name ILIKE '%sardine%'`);console.log(result.toArray());</script>
2.7 Apply the quality flags
measurement_qual carries each dataset’s own flag vocabulary, uninterpreted — bottle (6 = OK but from CTD, 8 = suspect, 9 = missing), CTD cast files (1/2 = use the primary/secondary sensor, 8 = questionable, 9 = bad/missing), DIC WOCE (2 = good, 3 = questionable, 4 = bad, 9 = missing); the registry is metadata/measurement_qual.csv. A flagged value is still a row. obs_bio and obs_env carry the verdict as a column, qual_ok, and the packages expose the predicate it was computed with, so a filter reads the same in every language:
WHERE qual_ok -- obs_bio, obs_env: the materialized verdict
calcofi4r::cc_qual_ok_sql("o") # the predicate, for obs / obs_ctd_full / sample_measurement
calcofi4py.qual_ok_sql("o")
The predicate is NULL-safe — an unflagged row is kept — and every CalCOFI app applies it. A 2.18 ml/L oxygen spike at 1,144 m that had been flagged suspect since 1955 reached a plot in 2026 because no consumer did; that is why the column exists.
2.8 Matching biology to environment
Relating a net tow’s catch to the water it was towed through is the question the database exists to answer, and calcofi4r answers it in one call: the three wrappers of Table 2.3 match biological observations to environmental ones in time and space, on the fly, against the release.
Table 2.3: The three matching wrappers and the biological side each takes.
function
biological side
cc_match_ichthyo_by_name()
ichthyoplankton, filtered by scientific name
cc_match_ichthyo_by_taxon()
ichthyoplankton, a WoRMS taxon and all its descendants (a recursive walk of taxon.parent_taxon_key)
d <-cc_match_ichthyo_by_name("Sardinops sagax", env_var ="temperature", life_stage ="larva",date_min ="2018-01-01", date_max ="2018-03-31",relax_matching =TRUE) # 5 km / 72 h instead of the default 2 km / 6 hcat(attr(d, "sql")) # the exact, portable SQL that produced itstr(attr(d, "query_meta")) # release version, parameters, source URLs
Each returns one row per biological observation with the matched environmental value, and attaches the exact SQL that produced it: paste attr(d, "sql") into the DuckDB shell, Python or the query site and the same rows come back. return_sql = TRUE gives the SQL without running it. The wrappers are thin shells over cc_match_bio_env(bio, env, …), which takes a biological and an environmental SELECT and performs the match — call it when the wrappers’ filters are not yours. Its knobs: max_dist_km and max_time_hr (2 km / 6 h; relax_matching = TRUE widens to 5 km / 72 h), join_method ("nearest_time", "nearest_dist" or "average" over the window), life_stage, date_min/date_max, depth_m_min/depth_m_max, version.
Note
The example asks for 2018 because the environmental and biological series end at different times in the release: the bottle series covers 1949-02 to 2021-05, the ichthyoplankton series 1951-01 to 2023-01 (measured, not typed — The database). Pick a period both cover.
2.9 Reproducible
Every query is plain SQL against immutable, versioned, public parquet, so a CalCOFI result is reproducible by anyone who has the version and the SQL. Pin a consolidated version (Releases) — its bytes are kept indefinitely — and keep the SQL: what the matching helpers attach as attr(d, "sql"), what the Explorer copies beside every download, what the query site shows in its SQL panel and what a hand-written query is are one and the same thing, in R, Python, the shell and the browser.
TipThe retired API, endpoint by endpoint
The Plumber API at api.calcofi.io served pre-built match tables from a PostgreSQL database that no longer exists. Every endpoint has a replacement that reads the release directly:
Table 2.4: What each retired api.calcofi.io endpoint became.