Backends
One section per backend: its extra, tier and flags, the locator fields it reads, its [backends.<kind>] config, its state, how pins and forks map onto the system, its handle, and what diff --content reports. Credentials are never stored in manifests.
| Kind | Extra | Maturity | Tier | Flags |
|---|---|---|---|---|
file |
– (local) / objectstore |
stable | Observed; Addressable for a versioned remote object | CHEAP_FINGERPRINT, DIFF, CREATE (local directory) |
icechunk |
icechunk |
stable | Forkable | ATOMIC_REF, DIFF, HISTORY, PROMOTE, CREATE; CONDITIONAL_REF on s3:// |
git |
– | stable | Forkable | CHEAP_FINGERPRINT, ATOMIC_REF, CONDITIONAL_REF, DIFF, HISTORY, PROMOTE, MERGE, CREATE |
lance |
lance |
stable | Forkable | ATOMIC_REF, DIFF, HISTORY |
delta |
delta |
stable | Addressable | CHEAP_FINGERPRINT, RETENTION_BOUND, DIFF, HISTORY |
neon |
neon |
experimental | Forkable | NEEDS_QUIESCENCE, RETENTION_BOUND, BRANCH_IS_STORAGE |
iceberg |
iceberg |
experimental | Forkable | RETENTION_BOUND, DIFF, HISTORY, PROMOTE |
ducklake |
ducklake |
experimental | Addressable | CHEAP_FINGERPRINT, RETENTION_BOUND, DIFF, HISTORY |
dolt |
dolt |
experimental | Forkable | ATOMIC_REF, DIFF, HISTORY, PROMOTE, MERGE |
memory |
– | stable | Forkable | CHEAP_FINGERPRINT, ATOMIC_REF, CONDITIONAL_REF, DIFF, HISTORY, PROMOTE, MERGE, CREATE (tests only) |
Maturity is what the backend declares (MATURITY), and tether backends prints it. Stable means the full lifecycle (register, commit, fork, write, commit, promote, gc) runs in CI against the real system: embedded stores and libraries, on local storage. The S3, GCS and Azure paths of file and icechunk are not yet cloud-tested; ROADMAP.md tracks that. Experimental means tested against a fake of a network service, not the service itself, and add says so. The tiers and flags are defined in Writing a Backend.
Conventions
- branch in a locator is the upstream branch (default
main), which the trunk bookmark stands for. Other bookmarks forktether.ws.<dataset8>.<bookmark>from the pin. - Pins are named
tether.<pin_id>, wherepin_idis<dataset8>.<hash16>: the dataset’s id, then a content address of kind, identity and state. Backends treat the id as opaque. - identity is the subset of the locator that names the system. Two objects of one dataset with the same identity and state share one pin, which commit cuts once; the second object is recorded at the state the pin was taken at.
- A local path’s identity is its resolved form: symlinks followed,
.,.., doubled and trailing slashes gone, andfile://folded. So/p,/p/,file:///pand a path through a symlinked parent are one store to pin ids, listings andgc. Re-pointing a symlink therefore changes the store’s identity: its pins stay safe (gcnever releases a pin any manifest names), but listings stored under the old identity are not found. - Locators are committed and read from every directory and clone, so a relative local path is resolved against the directory add or
importran in, and stored absolute; a leading~is expanded. That coversfileuri/path,gitpath, theicechunk,lanceanddeltauri, and DuckLake’s data_path and the path in metadata (afterducklake:,duckdb:orsqlite:). URLs and absolute paths are stored as given. - Objects in one branch scope (two databases of a Neon project, two keys on one Icechunk store) share a branch per bookmark, and
gccollects their references per scope. atis an object’s initial position (tether add --at/--pick), pinned by the first commit until a trunkpullmoves the object onto the branch. What each system accepts, and whattether loglists:
| Kind | at accepts |
log lists |
|---|---|---|
icechunk |
snapshot id, tag, branch | Repository.ancestry from the ref: id, written_at, message |
lance |
version number (on the base branch) or tag | versions() of the branch, newest first, tags on each |
iceberg |
snapshot id or ref name | snapshot ancestry with operation and record counts |
delta |
version number | transaction log with operation and metrics |
ducklake |
snapshot id | ducklake_snapshots with each snapshot’s changes |
dolt |
commit hash, tag, or branch (HASHOF) |
DOLT_LOG(ref) |
git |
any commit-ish (alias of ref) | git log with decorations |
neon, file |
not supported | – |
What the flags mean in practice
PROMOTE and MERGE. PROMOTE fast-forwards the base branch to a fork (Icechunk reset_branch, Iceberg set-snapshot-ref, git update-ref or merge --ff-only, Dolt merges). MERGE three-way merges a diverged fork (git merge, DOLT_MERGE). Lance cannot move a branch head yet, and Neon cannot promote a child branch, so both are refused with instructions. Where Icechunk and Lance stand upstream on branch merge, and what blocks it, is a dated survey in docs/upstream-branch-merge.md.
CONDITIONAL_REF means a fork, promote or merge moves the ref only from the head the plan reviewed, in one atomic step. The others compare just before the move (conditional moves).
CREATE (experimental) is what tether add --create needs (Stores tether created). Lance, Delta and Iceberg tables need a schema, so they cannot be created empty. Neon, Dolt and DuckLake objects live inside a project, database or catalog tether does not own. Make those with their own tools and add them. --pin record removes PIN from any backend (Lever 1).
State keys: content and address
A state’s keys do two jobs:
- Content keys say what the data is: an Icechunk snapshot id, a Neon
commit_xid, a git sha. Equality, pin ids and listing names use only these. - Address keys say where to find it, and can move without the data changing: Neon’s
lsn, and git’s change_id and dirty. Backends list them in VOLATILE_KEYS; open, pin and verify get the whole state.
Content diffs
tether diff A B compares manifests. --content also asks each changed object’s backend for its native diff, from metadata only, concurrently:
| Kind | Unit | Source | Example detail |
|---|---|---|---|
file (dir / prefix) |
files | two stored listings (.tether/listings/) |
modified a.tif +4.0 KiB |
file (object) |
objects | recorded size / etag / version | etag a -> b, version v1 -> v2 |
git |
files | git diff --name-status + --numstat |
modified main.py +1 -1 |
icechunk |
nodes | Repository.diff(from, to) |
modified /x 2 chunks |
iceberg |
snapshots | snapshot summaries along b’s ancestry to a |
append: +5 rows, +1 files |
delta |
commits | transaction-log history() in (a, b] |
WRITE: +2 rows, +1 files |
lance |
fragments | fragment ids / deletions / schema at both versions | added fragment 1 +1 rows |
ducklake |
tables | snapshot changes + ducklake_table_changes |
modified main.t +5000 rows, -1 rows |
dolt |
tables | dolt_diff_summary + dolt_diff_stat |
modified t +6 rows |
neon |
– | no native diff |
A directory or prefix state is only a digest, so at commit time the file backend also hands the engine a per-file listing. It is stored content-addressed under .tether/listings/ and committed with the manifests, like DVC’s .dir files. diff --content compares two listings file by file, or falls back to a count and size summary. Entries are capped at 2000 per object; the counts are exact.
file: local paths and object stores
Plain artifacts: a local file or directory, or an object / prefix in S3 (s3://), GCS (gs://), or Azure Blob (az://, abfs://), through obstore.
| Locator | uri (positional; path accepted). A trailing / (or empty key) means a prefix. region overrides the store region. |
| Config | storage_options = {...} passed to obstore.store.from_url. Committed: only region, allow_http, virtual_hosted_style_request and conditional_put; endpoints, account names and credentials only in .tether/secrets.toml. HTTP client options (allow_http, timeouts, proxies, …) work in any spelling there (AWS_ALLOW_HTTP, aws_allow_http, or inside client_options); a number is a duration in seconds, and an unknown key raises ConfigError. Otherwise credentials come from the environment or instance metadata. |
| Identity | {uri}, a local path in its resolved form (Conventions) |
| State | local file: type=file, size, sha256; local dir: type=dir, count, size, digest (digest over per-file sha256s); remote object: type=object, size, etag[, version_id]; prefix: type=prefix, count, size, digest (one paged LIST, etag+size per key). Local hashes are cached by (size, mtime_ns, ctime_ns, inode) in .tether/cache/file-hashes.json, so only files whose stat changed are re-read |
| Tier | Observed by default. --file versioned on a single remote object makes it Addressable via version_id (S3 version id / GCS generation / Azure version id; requires versioning on the bucket/container – a recorded state without a version id is refused by open). Local paths and prefixes are always Observed. |
| Pin / fork | none |
| Handle | FileHandle(uri, version_id) – always read-only |
| Listing | directories and prefixes hand the engine a per-file JSONL listing ({"p": path, "k": token, "s": size}) that is stored under .tether/listings/ |
| Diff | files: per-path added/removed/modified with size deltas from two stored listings; falls back to a count/size summary when a listing is missing; single objects report changed size/etag/sha256/version |
| Create | a local directory only (mkdir plus a .tether-owner marker file the directory walk skips, so the directory fingerprints as empty until something is written). Ref-empty means nothing but the marker; delete_store is an rmdir that refuses anything else. Remote prefixes are not created or removed by tether. |
| Notes | --file immutable (default) turns any drift into an ImmutableObjectModified error at status; drift means the bytes changed, not the timestamp. A symlink to a file is read through. A symlink to a directory, or a dangling one, is recorded by its target: retargeting it is a change, but edits under a linked directory are not seen. Fingerprint cost: Performance. |
icechunk: Icechunk repositories
| Locator | uri (local path or s3://bucket/prefix), branch (default main), region (S3) |
| Config | none committed. .tether/secrets.toml may set profile, role_arn, literal keys, endpoint_url and region per URI prefix or object (two identities); otherwise S3 credentials come from the environment (from_env). A profile or role becomes a get_credentials callback that refreshes the keys, so a long-lived session does not expire mid-write; an icechunk without the callback gets static keys, rebuilt when they rotate. For an S3-compatible server (SeaweedFS, MinIO), allow_http = true and force_path_style = true in the same entry reach every Icechunk and obstore call, add --create and delete_store included. |
| Identity | {uri}, a local path in its resolved form |
| State | snapshot_id of the branch head |
| Pin | create_tag("tether.<id>", snapshot_id); tags are immutable, and the default expire_snapshots keeps a tagged snapshot. expire_snapshots(delete_expired_tags=True) deletes the tag and the snapshot with it; verify then reports the pin missing |
| Fork | create_branch(name, snapshot_id) off the tag, or reset_branch if it exists. A conditional move uses reset_branch(from_snapshot_id=) (icechunk 2.x), which is atomic on object stores only: on local filesystem storage racing processes can each win |
| Handle | IcechunkHandle(repository, session, branch, tag, snapshot_id); session is writable for a branch, read-only for a tag/snapshot |
| Diff | nodes: Repository.diff(from, to) – new/deleted groups and arrays, updated metadata, updated chunk counts per array, moves |
| Create | Repository.create, refused if anything exists at the path or under the prefix. The owner marker is the tether.owner key in the repository’s metadata; an icechunk without repository metadata (2.2.0 works) lacks CREATE. Ref-empty: no branch but the base, no tag beyond the pins the plan names, and the base still at the first snapshot. delete_store re-checks owner and emptiness, then removes only Icechunk’s own top-level names; anything else there is refused and kept. |
neon: Neon Postgres
Experimental: tested against a fake of the Neon API, not a live project.
| Locator | project_id, database, role (all required, since Neon builds no connection URI without them), branch (default main) |
| Config | api_key_env (default NEON_API_KEY), api_url (default Neon API v2), protected_pins (default false; see Caveats: Neon), in .tether/secrets.toml only |
| Identity | {project_id, branch}: a Neon branch at an LSN is a snapshot of the whole project, so database and role do not change pin ids. Two objects on two databases of one project share one pin per commit, and one working branch per bookmark |
| State | lsn, commit_xid, branch, timeline, probed over SQL. Content is commit_xid (the newest committed transaction id) and branch (the lineage); lsn and timeline (the branch the LSN was read on) are volatile. With no commit among the last 65536 ids, commit_xid reads <N, so a later commit still shows as a change. What this means for equality: Caveats: Neon |
| Pin | a child branch tether.<id> of the state’s timeline at its LSN (so a fork’s pin hangs off the fork, not main), protected only with protected_pins = true; RETENTION_BOUND by the project’s history retention |
| Fork | a child branch of the pin. Re-forking an existing working branch restores it onto the source. Once a pin hangs off the working branch Neon will not restore it in place, so the fork lands on a sibling (<name>.2, .3, …) while the old branch stays the pins’ parent, and the engine records the returned name. A branch tether reads or writes gets one read_write endpoint, created once however many objects ask at the same time; pins and time-travel reads get a read_only one, and each connection URI names its endpoint |
| Quiescence | commit refuses while pg_stat_activity shows active writers (--force skips) |
| Handle | NeonHandle(url, branch) – a Postgres connection URL |
| Diff | not supported (no native primitive) |
| Notes | pins are branches, and branches are the storage: what that means for gc, new, promote, cost and fingerprinting is in Caveats: Neon. |
git: git and jj repositories
Pin the code (or any git-tracked data) that produced a dataset. Colocated jj repositories work through their .git.
| Locator | path (local checkout, stored absolute, and outside the dataset’s own checkout; the positional locator works too: tether add code --kind git ../code), ref (default HEAD), remote (push pins there: the name of a remote the repository has configured; a URL or path only as [objects."<key>"] remote in .tether/secrets.toml), allow_dirty (pin despite uncommitted changes) |
| Config | git_path, jj_path – .tether/secrets.toml [vcs] or TETHER_GIT/TETHER_JJ only; a committed value is refused |
| Identity | {path} (resolved absolute) |
| State | sha; dirty (only meaningful – and only computed – for the checked-out ref); change_id when jj is available. Both dirty and change_id are volatile: the same sha is the same state – and pins identically – whether or not jj is installed or the checkout has stray files; pin still reads the full state and refuses a dirty tree |
| Pin | lightweight tag tether.<id> at the sha. With remote set the pin is pushed and is only a pin once the push succeeds: a failed push fails the pin and removes the local tag; unpin deletes on the remote first and fails if that fails, so local and remote never diverge silently |
| Fork | branch name at the tag’s sha. A conditional move is update-ref with the old value, atomic; a branch checked out in any worktree is refused |
| Handle | GitHandle(path, sha, worktree) |
| Diff | files: git diff --name-status -M with --numstat line counts (+a -d) |
| Create | git init -b <ref> plus one empty root commit with a fixed tree, author and dates, so every created store has the same root sha (GitBackend.EMPTY_ROOT_SHA); the owner marker is git config tether.owner. Ref-empty: no branch or tag beyond the base, the base at that root sha, and a clean working tree, since an untracked, staged or ignored file is data. delete_store re-checks both before it removes the directory. |
| Notes | remote-only (url) repositories are not supported yet. Every git call runs with core.fsmonitor, hooks and ext:: transports off and safe.bareRepository=explicit, so a repository’s config runs nothing; git 2.38 is required. promote and merge move a branch. With the default ref = HEAD that is the checked-out branch, and a detached HEAD (a colocated jj checkout always has one) is refused: set ref to the branch name. A merge, or a promote onto a checked-out base, compares the head and then merges in the checkout, so a commit landing in between is merged or refused, never discarded. |
iceberg: Apache Iceberg tables
Experimental: driven through a fake of the pyiceberg table API; not yet run against a catalog service.
| Locator | identifier (ns.table; table accepted), branch (default main), catalog_name (default default), catalog (dict of pyiceberg catalog properties, or set once in config) |
| Config | catalog = {...} pyiceberg properties. Committed (tether.toml or the locator): exactly type, warehouse and name; every other property – uri, token, credential, … – only in .tether/secrets.toml ([backends.iceberg] catalog or per identifier) |
| Identity | {identifier, branch, catalog_name} |
| State | snapshot_id (nothing else: metadata_location changes on every table commit on any branch). A new table with no snapshot yet has snapshot_id = -1, Iceberg’s “no current snapshot” |
| Pin | tag tether.<id>; with --pin record, the snapshot id alone, recoverable within snapshot retention (for S3 Tables and other catalogs where user refs disable maintenance). The empty state needs no tag: its pin verifies as held and opens at snapshot_id = -1, which pyiceberg’s scans refuse rather than read today’s data |
| Fork | create_branch(name, snapshot_id); an existing name is re-pointed (set-snapshot-ref). A table with no snapshot cannot be forked, so write it on the trunk first. Needs pyiceberg 0.11 or newer |
| Handle | IcebergHandle(table, ref, snapshot_id) |
| Diff | snapshots: one entry per snapshot on b’s ancestry back to a, from snapshot summaries (append: +5 rows, +1 files); when a is not an ancestor, total-* counters are compared instead |
delta: Delta Lake tables
| Locator | uri (positional), region (sets AWS_REGION) |
| Config | storage_options = {...} passed to DeltaTable; committed, the same four keys as file, and the rest in .tether/secrets.toml |
| Identity | {uri}, a local path in its resolved form |
| State | version, table_id (metadata id – a recreated table verifies as drifted) |
| Pin / fork | none: Delta has no tags or branches. Versions stay readable until VACUUM (7-day default) / log retention (30 days). |
| Handle | DeltaHandle(uri, version, table) – read-only DeltaTable at the version |
| Diff | commits: one entry per transaction-log commit in (a, b] with its operation and metrics (WRITE: +2 rows, +1 files) |
lance: Lance datasets
| Locator | uri (positional), branch (default main) |
| Config | storage_options = {...} passed to lance.dataset; committed, the same four keys as file, and the rest in .tether/secrets.toml |
| Identity | {uri}, a local path in its resolved form |
| State | branch, version, and on any branch but main its branch_id (Lance version numbers are branch-scoped and restart when a branch is deleted and re-created, so the id tells two lives of one name apart; --at N means version N of the locator’s branch – it is a base coordinate and is never consulted once a working branch exists; use a tag to name a version on another branch) |
| Pin | tags.create("tether.<id>", (branch, version)); tagged versions are exempt from cleanup_old_versions() |
| Fork | create_branch(name, tag). Lance refuses to delete a branch a tag references, so a pinned working branch outlives its workspace and a re-fork under the same name picks name.2, name.3, … A conditional fork onto an existing branch compares its head, then deletes and recreates it, which leaves a small window; one expecting “absent” is atomic |
| Handle | LanceHandle(uri, dataset, version, branch, tag); append with lance.write_dataset(table, handle.dataset, mode="append") |
| Diff | fragments: fragment ids added/removed, deletion-file changes, columns added/removed, from the two manifests |
| Notes | an untouched fork reports its parent’s (branch, version), so a no-op commit after tether new is a no-op. |
ducklake: DuckLake catalogs
Experimental: tested against a local DuckDB-file catalog, not a Postgres or MySQL catalog with an object-store data_path.
| Locator | metadata (positional uri accepted; the ducklake: prefix is added if missing, e.g. ducklake:postgres:dbname=lake), data_path, table (scopes the handle only; snapshots are catalog-wide). A DuckDB or SQLite file path in metadata (bare, duckdb: or sqlite:), and data_path, are stored absolute with ~ expanded; a pre-0.1.0b4 relative path is made absolute by tether upgrade |
| Config | init_sql = ["CREATE SECRET ...", ...] run on every fresh connection – .tether/secrets.toml only (it runs code); credentials there or in the environment, never in the locator |
| Identity | {metadata[, data_path]} |
| State | snapshot_id, snapshot_time_us (epoch microseconds; a recreated catalog verifies as drifted) |
| Pin / fork | none: DuckLake has no tags or branches. ducklake_expire_snapshots bounds how long a snapshot stays readable. |
| Handle | DuckLakeHandle(metadata, alias, connection, snapshot_id, attach_sql, data_path, table): a DuckDB connection with the catalog attached read-only (at SNAPSHOT_VERSION for a recorded state); table_ref() gives alias.table; close() / with handle: detaches |
| Diff | tables: per-table created/dropped/altered/inserted/deleted from the snapshots’ changes maps, with row counts from ducklake_table_changes for tables that still exist. A table is named by its path, a part that is not a plain identifier double-quoted: sales.europe.orders, "a.child".t |
| Notes | a DuckDB-file metadata catalog can be attached only once per process: operations attach/detach per call (serialized per catalog), and an open handle or your own writer connection in the same process blocks status. Postgres/SQLite/MySQL catalogs have no such limit. |
dolt: Dolt databases
Experimental: tested against a fake of the Dolt SQL surface, not a running dolt sql-server.
| Locator | host (default 127.0.0.1), port (default 3306), database (required), branch (default main), user; or url = mysql://user@host:port/database |
| Credentials | only the server’s [uris."mysql://host:port"] entry in .tether/secrets.toml (a mysql://host:port/database entry beats it): user and password, or user_env and password_env. A manifest chooses the host, so a server with no entry gets no password (and user root unless the locator names one) |
| Identity | {host, port, database} |
| State | commit (branch head hash), plus dirty = true when dolt_branches.dirty reports uncommitted working-set changes (refused at pin time) |
| Pin | CALL DOLT_TAG('tether.<id>', commit) |
| Fork | CALL DOLT_BRANCH(name, tag) (recreated with -D if it exists at another commit) |
| Promote / merge | DOLT_MERGE (--ff-only to promote) inside an explicit transaction, the only way Dolt reports which tables conflict or violate a constraint. Those come back as MergeConflict, and the merge is rolled back |
| Handle | DoltHandle(url, database, ref, commit): a revision-database URL mysql://user@host:port/db/<branch|tag|hash> (tags and hashes are read-only in Dolt); database_ref is db/ref for USE |
| Diff | tables: dolt_diff_summary (added/dropped/modified/renamed tables, schema changes) with dolt_diff_stat row counts |
| Notes | talks to dolt sql-server / Hosted Dolt via PyMySQL; the SQL surface sits behind the DoltClient protocol (tether.experimental.backends.dolt) so other transports can be substituted. |
memory: in-process reference backend
The executable specification of the protocol, used by the conformance suite and the engine tests. Locator {system, branch}; state snapshot_id; tags and branches in a process-global MemoryStore (tether.backends.memory.default_store()). MemoryHandle.read() / .write(payload) operate on the current ref; diff reports changed payload keys.