Versioned DuckDB databases - snapshots, time travel, branches, clones and S3 export over native storage
Compatibility Matrix
This table covers all DuckDB releases that have not reached end-of-life, including LTS releases, and shows the 2 most recent patch releases of each. Older patch releases are not listed, but the extension may be available for them as well.
| Platform | 1.4.4 | 1.4.5 | 1.5.5 | 1.5.6 | 2.0.0 |
|---|---|---|---|---|---|
| Linux AMD64 | |||||
| Linux ARM64 | |||||
| macOS AMD64 | |||||
| macOS ARM64 | |||||
| Windows AMD64 | |||||
| Windows ARM64 | |||||
| WebAssembly |
Installing and Loading
INSTALL differential FROM community;
LOAD differential;
Example
-- Run in order in one DuckDB session, using fresh demo paths and database names.
-- Replace the absolute paths below; the S3 section also needs an existing bucket
-- and AWS credentials (environment variables, a profile, SSO or an instance role).
INSTALL differential FROM community;
LOAD differential;
SET differential_repository = '/absolute/path/differential-demo/repository';
-- LOCAL STORAGE: create, snapshot, read history, branch, clone, restore and clean up.
-- TYPE differential secrets hold storage defaults, never credentials.
CREATE SECRET local_storage (
TYPE differential,
DATA_PATH '/absolute/path/differential-demo/local-data'
);
ATTACH 'differential:sales' AS sales (SECRET local_storage);
CREATE TABLE sales.orders (id INTEGER PRIMARY KEY, amount INTEGER);
INSERT INTO sales.orders VALUES (1, 10), (2, 20);
CALL differential_create_snapshot('sales', name => 'baseline',
message => 'initial load', request_id => 'sales-baseline'); -- snapshot 1
INSERT INTO sales.orders VALUES (3, 30);
CALL differential_create_snapshot('sales', name => 'three-orders'); -- snapshot 2
SELECT sum(amount) FROM sales.orders; -- 60
SELECT sum(amount) FROM sales.orders AT (VERSION => 1); -- 30
ATTACH 'differential:sales' AS sales_history (SNAPSHOT_NAME 'baseline');
SELECT count(*) FROM sales_history.orders; -- 2
SELECT snapshot_id, snapshot_name FROM sales.snapshots();
DETACH sales_history;
-- A branch shares the database identity; a clone is an independent database.
CALL differential_create_branch('sales', 'dev', snapshot => 1);
ATTACH 'differential:sales' AS sales_dev (BRANCH 'dev');
INSERT INTO sales_dev.orders VALUES (4, 40);
CALL differential_create_snapshot('sales_dev', message => 'dev order'); -- snapshot 3
SELECT sum(amount) FROM sales_dev.orders; -- 70
DETACH sales_dev;
CALL differential_clone_database('sales', 'sales_copy', snapshot => 1);
ATTACH 'differential:sales_copy' AS sales_copy;
SELECT sum(amount) FROM sales_copy.orders; -- 30
DETACH sales_copy;
-- Merge dev back into main: a three-way merge against their common ancestor
-- (snapshot 1). main added order 3 and dev added order 4, so nothing conflicts.
SELECT base_snapshot_id, ours_snapshot_id, theirs_snapshot_id, conflicts, validation_state
FROM differential_merge_branch_preview('sales', 'main', 'dev', validate => true); -- 1, 2, 3, 0, valid
-- Merging into an existing branch (destination = ours) needs it detached.
-- DETACH also releases the hidden reader pins taken by AT queries.
DETACH sales;
SELECT snapshot_id, merge_action FROM differential_merge_branch('sales', 'main', 'dev', 'main',
request_id => 'sales-merge-dev'); -- snapshot 4, merged
ATTACH 'differential:sales' AS sales;
SELECT sum(amount) FROM sales.orders; -- 100
DETACH sales;
-- Restore also requires a detached branch and preserves its previous contents.
CALL differential_restore('sales', 1); -- restored snapshot 5
ATTACH 'differential:sales' AS sales;
SELECT sum(amount) FROM sales.orders; -- 30
DETACH sales;
CALL differential_drop_branch('sales', 'dev');
CALL differential_expire_snapshots('sales', versions => [2, 3, 4], dry_run => true);
CALL differential_expire_snapshots('sales', versions => [2, 3, 4]);
CALL differential_cleanup_old_files(cleanup_all => true);
CALL differential_delete_orphaned_files(dry_run => true);
CALL differential_compact();
-- The main branch, baseline snapshot and independent clone remain usable.
-- S3 STORAGE: credentials, exports, history, remote reads and recovery.
-- Replace the bucket/prefix and region. The bucket must already exist.
INSTALL httpfs;
LOAD httpfs;
INSTALL aws;
LOAD aws;
-- TYPE s3 supplies credentials by matching SCOPE; these secrets are session-scoped.
-- REFRESH auto keeps temporary credentials current through the credential chain.
CREATE SECRET s3_auth (
TYPE s3,
PROVIDER credential_chain,
REFRESH auto,
REGION 'us-east-1',
SCOPE 's3://my-bucket/differential-demo/'
);
CREATE SECRET s3_storage (
TYPE differential,
DATA_PATH 's3://my-bucket/differential-demo/'
);
ATTACH 'differential:archive' AS archive (SECRET s3_storage);
CREATE TABLE archive.events (id INTEGER PRIMARY KEY, payload VARCHAR);
INSERT INTO archive.events VALUES (1, 'one'), (2, 'two');
SELECT snapshot_id, export_state, export_locator
FROM differential_create_snapshot('archive', name => 'baseline',
request_id => 'archive-baseline'); -- snapshot 1, complete
-- Save the immutable export URI for recovery later in this session.
SET VARIABLE baseline_export = (
SELECT 's3://my-bucket/differential-demo/' || export_locator
FROM differential_snapshots('archive') WHERE snapshot_id = 1
);
-- Subsequent exports share unchanged data. Deferred exports can be retried safely.
INSERT INTO archive.events VALUES (3, 'three');
CALL differential_create_snapshot('archive', name => 'three-events',
export_mode => 'deferred'); -- snapshot 2, pending
CALL differential_retry_export('archive', 2); -- snapshot 2, complete
SELECT snapshot_id, export_state FROM archive.snapshots();
SELECT count(*) FROM archive.events AT (VERSION => 1); -- 2
-- Any host with the same scoped S3 credentials can open this branch head read-only.
-- It sees the latest exported snapshot at ATTACH time; reattach to refresh it.
ATTACH 'differential:s3://my-bucket/differential-demo/heads/archive/main' AS archive_latest;
SELECT count(*) FROM archive_latest.events; -- 3
DETACH archive_latest;
SELECT database_name, branch_name, snapshot_id
FROM differential_remote_heads('s3://my-bucket/differential-demo/');
DETACH archive;
-- Evict only an export-complete snapshot. Historical reads then fetch its data from S3.
CALL differential_evict_snapshots('archive', versions => [1]);
CALL differential_cleanup_old_files(cleanup_all => true);
ATTACH 'differential:archive' AS archive_history (SNAPSHOT_VERSION 1);
SELECT count(*) FROM archive_history.events; -- 2
DETACH archive_history;
-- Recover the saved immutable export into a new writable LOCAL database.
-- The named differential secret chooses its destination; s3_auth reads the source.
CALL differential_recover_export(getvariable('baseline_export'), 'archive_recovered',
secret => 'local_storage');
ATTACH 'differential:archive_recovered' AS archive_recovered;
SELECT count(*) FROM archive_recovered.events; -- 2
INSERT INTO archive_recovered.events VALUES (9, 'recovered locally');
DETACH archive_recovered;
-- Branch/clone/restore work with S3 too; evicted data is rehydrated when needed.
-- Branches and clones inherit the source's S3 data path.
CALL differential_create_branch('archive', 'dev', snapshot => 1);
ATTACH 'differential:archive' AS archive_dev (BRANCH 'dev');
INSERT INTO archive_dev.events VALUES (4, 'dev');
CALL differential_create_snapshot('archive_dev'); -- snapshot 3, complete
DETACH archive_dev;
CALL differential_clone_database('archive', 'archive_copy', snapshot => 1);
ATTACH 'differential:archive_copy' AS archive_copy;
SELECT count(*) FROM archive_copy.events; -- 2
DETACH archive_copy;
CALL differential_restore('archive', 1); -- snapshot 4, complete
ATTACH 'differential:archive' AS archive;
SELECT count(*) FROM archive.events; -- 2
DETACH archive;
CALL differential_drop_branch('archive', 'dev');
CALL differential_expire_snapshots('archive', versions => [2, 3], dry_run => true);
CALL differential_expire_snapshots('archive', versions => [2, 3]);
CALL differential_cleanup_old_files(cleanup_all => true);
CALL differential_compact();
-- Expiry and garbage collection reclaim unreferenced LOCAL data, not S3 objects.
-- Existing immutable S3 exports remain readable; the main branch and clone remain.
SELECT database_name, data_path FROM differential_databases();
SELECT kind, state FROM differential_operations();
DROP SECRET s3_storage;
DROP SECRET s3_auth;
DROP SECRET local_storage;
About differential
differential stores ordinary DuckDB databases in a durable, content-addressed differential
store, so every version shares unchanged data. Native DuckDB SQL, transactions and
indexes are unchanged; the extension adds immutable snapshots with commit metadata,
AT (VERSION/TIMESTAMP) time travel, writable branches, independent clones, restore,
expiry with garbage collection, and incremental, content-addressed S3 export that any
host can attach read-only, including following a branch's latest snapshot through S3
branch heads. Several processes, and optionally several local users, can share one
repository.
S3 databases can also:
- ship every commit to S3 continuously (
SHIP_WAL 'async' | 'sync'), so another host can recover the branch withdifferential_recover_branch; - be followed by read-only replicas that move to each new commit on their own
(
FOLLOW trueon a branch head); - be written by several hosts (
WRITERS 'shared'), with optimistic, conflict-checked publication through conditional S3 writes; - have unreachable S3 data collected (
differential_cleanup_remote_files).
Branches can be merged in local and single-writer S3 databases: a three-way merge against the best common ancestor that reconciles rows and cells by primary key (every table needs one):
-
differential_merge_branchmerges one branch into another, either into a new branch or into the existingoursbranch (a merge snapshot, fast-forward or already up to date);differential_merge_snapshotsmerges three explicit snapshots into a new branch; -
differential_merge_branch_preview,differential_merge_snapshots_previewanddifferential_merge_branch_preflightreport conflicts and readiness without writing; the reviewed snapshot IDs can guard the merge (expected_*_snapshot_id); - conflicts are resolved with explicit
resolution_tables;schema_mode'strict','additive'or'catalog'controls which schema changes merge; -
differential_merge_ancestryexplains base selection,differential_merge_statusshows running merges,differential_merge_requestsanddifferential_merge_resolutionsinspect durable, retryable requests, anddifferential_abort_mergeabandons a pending one; -
SET differential_merge_max_concurrentlimits concurrent merges per process.
Shared (WRITERS 'shared') databases cannot be merged.
The SQL surface follows DuckLake's conventions: ATTACH 'differential:<db>' options
(BRANCH, SNAPSHOT_VERSION, SNAPSHOT_TIME, SNAPSHOT_NAME, DATA_PATH),
SET differential_* settings and database-first differential_* functions.
Built for DuckDB 2.0. Store format 2 has been used since 1.0.0, which refuses repositories and exports written by 0.1.0; there is no migration. 1.1.0 opens 1.0.0 repositories, but once 1.1.0 has written to a repository, downgrading to 1.0.0 is not supported.
Status: experimental. Linux (amd64, arm64) and macOS (Apple silicon) only. Commits are durable at DuckDB's sync points; exports are durable once complete. S3 is tested against MinIO and exercised on AWS; power-loss behaviour is not yet verified. See the repository for the SQL reference, the lifecycle guide, branch merging and the storage format.
Added Functions
| function_name | function_type | description | comment | examples |
|---|---|---|---|---|
| differential_abort_merge | table | Aborts an unpublished merge by operation_id, releasing its target fence, reservation and retained result. Recovers a published S3 result instead; uncertain remote outcomes retain state. Serialized with merge attempts. | NULL | [SELECT * FROM differential_abort_merge('sales', 'merge-operation-uuid', request_id => 'abort-1');] |
| differential_branches | table | Lists the branches of a differential database with their head snapshot and, for shared databases, their state relative to the S3 branch head. | NULL | [SELECT branch_name, head_snapshot_id, remote_state FROM differential_branches('sales');] |
| differential_claim_branch_heads | table | Takes a single-writer database's S3 branch heads over after a copy of the repository wrote them. | NULL | [SELECT branch_name, snapshot_id FROM differential_claim_branch_heads('sales');] |
| differential_cleanup_metadata | table | Deletes completed operation records and history entries of expired snapshots older than older_than; request IDs of deleted operations no longer replay. | NULL | [SELECT table_name, rows FROM differential_cleanup_metadata(older_than => now() - INTERVAL 30 DAY, dry_run => true);] |
| differential_cleanup_old_files | table | Collects local data that no snapshot, branch, pin or pending export references any more. | NULL | [SELECT deleted_objects FROM differential_cleanup_old_files(cleanup_all => true);] |
| differential_cleanup_remote_files | table | Garbage-collects an S3 location in two phases: condemns data no branch head or unexpired snapshot references, and deletes what stayed condemned and unreachable since before older_than (default: a week ago). | NULL | [SELECT condemned_objects, deleted_bytes FROM differential_cleanup_remote_files('s3://my-bucket/lake/', dry_run => true);] |
| differential_clone_database | table | Creates an independent database from a snapshot of another one, on the same data path and sharing its data. | NULL | [SELECT database_id FROM differential_clone_database('sales', 'sales_copy');] |
| differential_compact | table | Returns free pages of the repository's metadata store to the file system. | NULL | [SELECT pages_reclaimed FROM differential_compact(max_pages => 1000);] |
| differential_create_branch | table | Creates a writable branch from a snapshot, sharing all data with it. | NULL | [SELECT branch_id FROM differential_create_branch('sales', 'dev', snapshot => 1);] |
| differential_create_snapshot | table | Checkpoints an attached writable branch and publishes it as an immutable snapshot with optional commit metadata; exports it to S3 and, for shared databases, moves the S3 branch head. | NULL | [SELECT snapshot_id, export_state FROM differential_create_snapshot('sales', name => 'v1', message => 'first load');] |
| differential_current_snapshot | table | Returns the snapshot an attachment reads, or the latest snapshot of the attached branch or database. | NULL | [SELECT id FROM differential_current_snapshot('sales');] |
| differential_databases | table | Lists the databases of the differential repository with their data path and writer mode. | NULL | [SELECT database_name, data_path, writers FROM differential_databases();] |
| differential_delete_orphaned_files | table | Removes files in local pools that no mapping references, left behind by crashes. | NULL | [SELECT orphaned_files FROM differential_delete_orphaned_files(dry_run => true);] |
| differential_drop_branch | table | Preserves a detached branch's contents as a snapshot and drops the branch (in a shared database, for every writer). | NULL | [SELECT preservation_snapshot_id FROM differential_drop_branch('sales', 'dev');] |
| differential_evict_snapshots | table | Drops the local copy of snapshots whose S3 export completed; they are then read through S3. | NULL | [SELECT snapshot_id FROM differential_evict_snapshots('sales', versions => [1], dry_run => true);] |
| differential_expire_snapshots | table | Retires snapshots selected by version list and/or age so their local data can be collected; S3 exports remain. | NULL | [SELECT snapshot_id FROM differential_expire_snapshots('sales', versions => [1], dry_run => true);] |
| differential_followers | table | Lists this process's followers (ATTACH … FOLLOW true): what each follows, the state it reads, when it last checked S3 and its last error. | NULL | [SELECT alias, follows, epoch, checked_at FROM differential_followers();] |
| differential_import_database | table | Copies an offline native DuckDB file into a new differential database and publishes it as snapshot 1. | NULL | [SELECT database_id FROM differential_import_database('/data/native.duckdb', 'imported');] |
| differential_merge_ancestry | table | Inspects recorded local snapshot parents and best common ancestor candidates, including expired history. Incomplete and ambiguous history is reported without guessing a base. | NULL | [SELECT * FROM differential_merge_ancestry('sales', 2, 3);] |
| differential_merge_branch | table | Merges published local or single-writer S3 branch heads using a unique proven best common ancestor. Destination equal to ours updates that detached, clean target; otherwise creates a new branch. Returns merge_action (created_branch, merged, fast_forward, already_up_to_date). Optional expected_base_snapshot_id/expected_ours_snapshot_id/expected_theirs_snapshot_id must be supplied together and guard the reviewed selection. The request records its input snapshot selection and schema_mode once; source live changes are excluded. schema_mode => 'additive' allows supported new tables and nullable column additions; 'catalog' also merges table/nullable-column removals and view definitions. | NULL | [SELECT * FROM differential_merge_branch('sales', 'ours', 'theirs', 'merged', request_id => 'merge-1');] |
| differential_merge_branch_preflight | table | Checks local or single-writer S3 branch merge readiness without publishing or recording a request. Returns selected input IDs, expected action, destination/target state, conflict counts and bounded diagnostics including pending recovery operations. Native final-state validation defaults to true. Checks are advisory; use expected snapshot IDs when executing. | NULL | [SELECT * FROM differential_merge_branch_preflight('sales', 'ours', 'theirs', 'ours');] |
| differential_merge_branch_preview | table | Previews published branch heads using a unique proven best common ancestor. Returns resolved input IDs, counts and bounded typed conflicts; validate => true checks final constraints. schema_mode => 'additive' allows supported new tables and nullable column additions; 'catalog' also merges table/nullable-column removals and view definitions. Catalog conflicts are reported separately. | NULL | [SELECT * FROM differential_merge_branch_preview('sales', 'ours', 'theirs', validate => true);] |
| differential_merge_requests | table | Inspects a bounded page of durable merge requests without S3 access. Includes inputs, destination, retained result, frozen schema policy, original branch identities, input guards, target fences and recovery guidance. Latest-attempt diagnostics include a count, phase, safe error code and timestamps; incomplete does not imply liveness. Ordered by operation_id; after_operation_id is an exclusive pagination cursor. | NULL | [SELECT * FROM differential_merge_requests('sales', request_id => 'merge-1');] |
| differential_merge_resolutions | table | Reads the frozen typed resolution choices of a merge request. Does not read mutable resolution source tables. | NULL | [SELECT * FROM differential_merge_resolutions('sales', 'merge-1');] |
| differential_merge_snapshots | table | Merges three explicit snapshots into a new branch after native constraint validation and checkpointing. Supports local and single-writer S3 storage; S3 publication is synchronous and recoverable. Conflicts prevent publication. schema_mode => 'catalog' also merges table/nullable-column removals and view definitions; 'additive' only allows supported table/column additions. The default is strict. | NULL | [SELECT * FROM differential_merge_snapshots('sales', 1, 2, 3, 'merged', request_id => 'merge-1');] |
| differential_merge_snapshots_preview | table | Compares three explicit snapshots of a local or single-writer S3 database using typed, cell-level merge rules. Returns per-table counts and byte-bounded typed conflict details; validate => true checks final constraints in disposable staging. schema_mode => 'additive' allows supported new tables and nullable column additions; 'catalog' also merges table/nullable-column removals and view definitions. Catalog conflicts are reported separately. | NULL | [SELECT * FROM differential_merge_snapshots_preview('sales', 1, 2, 3);] |
| differential_merge_status | table | Lists active merges and previews in this process and repository, with phase, progress and resource counters. Durable request outcomes are in differential_merge_requests(). | NULL | [SELECT phase, tables_completed, tables_total FROM differential_merge_status();] |
| differential_operations | table | Lists the durable operation records of the repository, used to replay requests by request_id. | NULL | [SELECT kind, state, snapshot_id FROM differential_operations();] |
| differential_providers | table | Lists the repository's storage locations (metadata store, local pools, S3 locations) with their S3 configuration and credential source, never credentials. | NULL | [SELECT data_path, credential_source FROM differential_providers();] |
| differential_recover_branch | table | Recovers the newest shipped state of a branch (SHIP_WAL) from S3 into a new local database: its chain's base, read on demand, plus every shipped epoch; DuckDB replays the WAL at the first ATTACH. same_identity => true keeps the original identities (a failover of the writer). | NULL | [SELECT database_id, shipped_epoch FROM differential_recover_branch('s3://my-bucket/lake/heads/sales/main', 'sales_recovered');] |
| differential_recover_export | table | Copies an exported snapshot from S3 into a new writable differential database, verifying every chunk. | NULL | [SELECT database_id FROM differential_recover_export('s3://my-bucket/lake/descriptors/ |
| differential_remote_heads | table | Lists the branch heads exported to an S3 location, each with the path to ATTACH its latest snapshot. | NULL | [SELECT database_name, branch_name, head FROM differential_remote_heads('s3://my-bucket/lake/');] |
| differential_resolve_conflict | table | Reconciles a diverged branch of a shared database with its S3 head, keeping local work as a new branch or as a local snapshot. | NULL | [SELECT kept_snapshot_id, kept_branch FROM differential_resolve_conflict('team', keep => 'branch');] |
| differential_restore | table | Resets a detached branch to a snapshot after preserving its current contents as a snapshot. | NULL | [SELECT snapshot_id, preservation_snapshot_id FROM differential_restore('sales', 1, branch => 'dev');] |
| differential_retention_roots | table | Lists why each stored database image is retained (snapshots, branches, pins, exports). | NULL | [SELECT reason, identity FROM differential_retention_roots();] |
| differential_retry_export | table | Finishes pending S3 exports of a database (all, or one snapshot) and republishes its branch heads. | NULL | [SELECT snapshot_id, export_state FROM differential_retry_export('sales');] |
| differential_snapshots | table | Lists every snapshot of a differential database (for a shared database also other writers' snapshots, read from S3) with commit metadata, publication and S3 export state. | NULL | [SELECT snapshot_id, snapshot_name, export_state FROM differential_snapshots('sales');] |
| differential_status | table | Reports the extension's capabilities, durability profiles, limits and last background publication error as capability/status rows. | NULL | [SELECT capability, status FROM differential_status();] |
| differential_storage_stats | table | Returns storage, S3, cache and timing counters of the repository and process as metric/value rows. | NULL | [SELECT metric, value FROM differential_storage_stats() WHERE metric = 'local_payload_bytes';] |
| differential_writers | table | Lists the advisory 'being written' markers of a shared database in S3: which hosts are writing which branches. | NULL | [SELECT branch_name, host, live FROM differential_writers('team');] |
Overloaded Functions
This extension does not add any function overloads.
Added Types
This extension does not add any types.
Added Settings
| name | description | input_type | scope | aliases | typed_value |
|---|---|---|---|---|---|
| differential_auto_gc | Run bounded differential garbage collection after native checkpoints | BOOLEAN | GLOBAL | [] | true |
| differential_export_staging | Where exports stage objects before uploading them: 'disk' (the repository's staging directory) or 'memory'. Process-wide | VARCHAR | GLOBAL | [] | disk |
| differential_export_verification | How exports verify each uploaded object: 'checksum' (the S3 service checks its SHA-256) or 'read_back' (also download and hash it). Process-wide | VARCHAR | GLOBAL | [] | checksum |
| differential_group_commit | Commit concurrent native syncs of different databases in one durable transaction (one device flush). Process-wide | BOOLEAN | GLOBAL | [] | true |
| differential_merge_max_concurrent | Concurrent merge/preview/abort executions per process (1-16); excess calls fail with merge_busy | BIGINT | GLOBAL | [] | 2 |
| differential_remote_cache_size | Size of the repository's disk cache for data read from S3 (e.g. '4GB'); 0 = off. Read when a remote file is opened | VARCHAR | GLOBAL | [] | 0 |
| differential_repository | Differential repository root (relative paths resolve against the working directory); set before first use | VARCHAR | GLOBAL | [] | /home/runner/.duckdb/differential |
| differential_repository_shared | Create the repository for several local users (group-writable); set before first use | BOOLEAN | GLOBAL | [] | false |
| differential_s3_max_requests | Parallel S3 requests per process (1-64), shared by all S3 locations and DuckDB instances in the process | BIGINT | GLOBAL | [] | 16 |
| differential_snapshot_on_checkpoint | Publish every completed native checkpoint of a writable branch as a public snapshot | BOOLEAN | GLOBAL | [] | false |
| temp_directory | Set the directory to which temporary files are written. | VARCHAR | GLOBAL | [] | post.db.tmp |