Versions
The version ID, the fields of version.json, the frozen catalog, and catalogs in other DuckLake formats.
A version is the files under versions/<version ID>/. They must not change once written, except:
expired.jsonis added and the catalogs deleted when the version falls out of retention (Data files)- on withdrawal,
expired.jsonis added with"reason": "withdrawn"and the catalogs deleted, andversion.jsonitself is deleted if it holds the withdrawn data (Data files) catalogs/<format>.duckdbmay be added later. Files already there are not changedversion.json.sigmay be added later, by a writer that signs a version after making it
Version ID
- A UUIDv7 in lower case with hyphens, for example
019a3c4e-7b21-7f3a-9c55-2f0d8e1a4b6c. IDs sort by creation time, and writers do not collide without coordinating - Not a content hash. The same catalog can be written as different bytes, so a hash would not be stable. Content is checked with
catalog.sha256 - The number shown to people is
seq. It is not unique across branches, so it is not the ID
version.json
{
"fdl": "0.1",
"id": "019a3c4e-7b21-7f3a-9c55-2f0d8e1a4b6c",
"seq": 12,
"parents": ["019a2f10-0c4d-7e88-a1b2-55c0d9e3f7aa"],
"created_at": "2026-10-30T09:00:00Z",
"author": { "name": "example" },
"message": "Add the final 2025 figures",
"catalog": {
"path": "ducklake.duckdb",
"storage": "duckdb-v1.5.0",
"bytes": 4194304,
"sha256": "9f2c…"
},
"dataset": {
"path": "dataset.json",
"bytes": 2048,
"sha256": "4c1d…"
},
"ducklake": {
"format": "1.0",
"snapshot_id": 345,
"snapshots": [301, 345]
},
"files": {
"path": "files.json",
"bytes": 1536,
"sha256": "7a0e…"
},
"changes": [
{ "table": "main.population", "kind": "add_column", "column": "note", "breaking": false }
],
"inputs": [
{
"source_id": "example:population-2025",
"sha256": "1b7e…",
"retrieved_at": "2026-10-29T03:12:00Z",
"url": "https://example.org/population-2025.csv"
}
],
"annotations": {}
}
| Field | Required | Meaning |
|---|---|---|
fdl |
must | The specification version it was written for |
id |
must | The same ID as the directory. A reader must not read a version where they differ |
seq |
must | The number shown to people: 1 for the first, the first parent’s seq plus 1 after |
parents |
must | Parent version IDs. Empty for the first version, one after |
created_at |
must | UTC, ISO 8601 |
author |
must | Who made it. name is required; email and others are optional |
message |
must | What changed |
catalog |
must | The catalog’s file name, size, sha256, and storage format (storage) |
dataset |
must | The dataset.json beside the catalog: its file name, size and sha256. Versions written before it was required lack it |
ducklake |
must | The DuckLake format, the snapshot frozen, and the range of snapshots this version adds after its parent (first and last) |
files |
may | The files.json beside the catalog, if written: its file name, size and sha256 (files.json) |
changes |
may | Column differences from the parent. breaking says whether SQL written for the parent may fail (a column dropped, renamed or recreated) |
inputs |
may | Source files used. How source_id is assigned is up to the writer |
annotations |
may | What the format does not interpret, keyed by the name of whoever reads it (a host, a tool) |
A reader must ignore fields it does not know.
In FDL 0.1 a writer writes at most one parent. parents is a list so that a later version of this specification can describe merges without changing the field; a reader must accept a list of any length and follow the first entry for history.
The description and SQL of each snapshot stay in the catalog, in DuckLake’s commit_message and commit_extra_info. They are not copied into version.json.
Format numbers
| Field | Refers to | Changes when |
|---|---|---|
fdl |
this specification | this specification changes |
ducklake.format |
the shape of DuckLake’s bookkeeping tables | DuckLake’s specification changes |
catalog.storage |
the storage format of the catalog file | DuckDB’s storage format changes |
A reader can tell from version.json alone whether it can open a version.
files.json
files.json is an index of each table’s data files at ducklake.snapshot_id, for readers that cannot open the catalog. The writer makes it from the catalog, and the catalog wins where the two differ. It is optional: a reader must work without it. It plays the part of Git’s reachability bitmap (.bitmap beside a pack): a list, made from the objects, of what one commit reaches, which speeds up reading and which a reader can do without.
{
"tables": {
"main.population": {
"files": [
{
"path": "main/population/ducklake-019a3c4e-….parquet",
"rows": 1741,
"bytes": 52311,
"footer_bytes": 2048,
"deletes": { "path": "main/population/ducklake-019a3c4f-…-delete.parquet", "rows": 3, "bytes": 812 }
}
]
}
}
}
tablesholds every table alive at the snapshot, keyedschema.table. A table with no rows has an emptyfilespathis relative to the repository’sdata_dir. Files are in the catalog’s orderrowsandbytesare the row count and size the catalog records.footer_bytesis the size of the Parquet footer, when recordeddeletesis the delete file for that data file, if rows were deleted from itrenamed_columnsistruewhen a column of the table was ever renamed. Data files written before the rename carry the old name
A reader that reads the Parquet files without DuckLake must not read a table whose files have deletes, or whose renamed_columns is true, unless it applies them as DuckLake does. Otherwise it returns deleted rows or misses renamed columns.
When a version is taken out of retention, files.json is deleted with its catalogs.
The frozen catalog
ducklake.duckdb is a DuckLake catalog in DuckDB’s file format.
- It holds bookkeeping rows up to
ducklake.snapshot_idand none after - It holds no inlined rows. All data is in data files
- Every data file it names is under
data_dir, recorded relative to the catalog’s data path. A writer must check this; a host may check it too - Its
data_pathis the absolute URL of the data folder where the writer published: the root plusdata_dir. Readers override it (see Reading) - Until DuckLake can tag snapshots, a writer must not run
ducklake_expire_snapshotson its working catalog in a way that removes the snapshot a version was frozen at
dataset.json
What a version holds, in words: the dataset, its tables and their columns. It is part of the version, written once with it, so a reader can tell what a version holds and under what terms without opening the catalog, and an old version keeps the description it was published with.
The writer describes; the writer’s tool checks the description against the frozen catalog and completes it from there.
{
"name": "population",
"title": "Population by town",
"description": "Census population of each town, by age and sex",
"keywords": ["population", "census"],
"language": "en",
"homepage": "https://example.org/population",
"issued": "2026-10-30",
"update_frequency": "yearly",
"licenses": [{ "id": "CC-BY-4.0", "spdx": "CC-BY-4.0", "url": "https://creativecommons.org/licenses/by/4.0/" }],
"sources": [{ "title": "2025 census", "path": "https://example.org/census-2025" }],
"contributors": [{ "title": "Example City", "roles": ["publisher"] }],
"temporal_coverage": { "start": "2025", "end": "2025" },
"spatial_coverage": ["JP"],
"schemas": [{ "name": "main", "title": "Tables" }],
"tables": [
{
"schema": "main",
"name": "population",
"title": "Population",
"description": "One row per town, age group and sex",
"primary_key": ["town_code", "age_group", "sex"],
"depends_on": [],
"sql": "SELECT …",
"rows": 120000,
"fields": [
{ "name": "town_code", "title": "Town code", "description": "…", "type": "varchar", "nullable": false }
]
}
],
"annotations": {}
}
nameandtitleare required. Everything else may be left out- A writer may keep its description as YAML (
dataset.yml), and may keep each table in its own file (<name>.table.yml, one entry oftables). The version always holds the whole description as one JSON - Each of
licensesis an SPDX identifier, or an object withidand what the writer wants to add (spdx,url,title) tableslists every table of the catalog’s last snapshot. The writer’s tool adds the tables the writer did not describe, with what the catalog knows of them, and refuses a described table or column the catalog lacks- A view of the catalog is listed after the tables with
"kind": "view"and itssqlfrom the catalog. The catalog records no columns for a view, so itsfieldsare the writer’s, withouttype,nullableorrows.kindis left out for a table - From the catalog: each table’s
rows, and each field’stype(DuckLake’s type name) andnullable. Comments in the catalog (COMMENT ON) are used where the writer gave nodescription - Keys this specification does not define are refused. What the format does not interpret goes under
annotations, keyed by the name of whoever reads it, at the top, in a table or in a field. Writers keep it as written - Versions written before
annotationshad its name carry it asextensions. A reader readsextensionsasannotations - What a license or a source says is the writer’s claim. A host may check it before it publishes
Catalogs in other formats
One version may carry a catalog per DuckLake format, so that a reader picks the file it can open instead of switching DuckDB.
versions/<ID>/
ducklake.duckdb the catalog as made (the one version.json describes)
catalogs/1.0.duckdb written out in format 1.0
catalogs/1.1.duckdb written out in format 1.1
- Every
catalogs/<format>.duckdbmust name the same data files and return the same rows - A reader tries the formats it can open, newest first, then falls back to
ducklake.duckdb - A writer that cannot produce a declared format for a version leaves that file out. A reader that can open none of the files of a version cannot read it
- A version that uses something an older format cannot express has no catalog in that format
- A format past its
untilinfdl.jsonis not written for new versions. Past versions may keep theirs - Data files must be written so that the oldest format declared in
fdl.jsoncan read them catalog.storageshould be the oldest DuckDB storage format the declared readers can open
DuckLake formats shows how to write an older format from DuckDB 2.0.