On this page
Experimental Loams House talks to an Iceberg REST catalog as a client today, tested against Lakekeeper, but the release stack runs the House on its local catalog. Planned Other engines reading Loams tables through a REST catalog is planned.
An Iceberg table is immutable files in a bucket plus one mutable pointer: the location of the table's current metadata file. The projects post on Iceberg and Lakekeeper covers the table format and why Lakekeeper is our catalog. This post is about the protocol that holds the pointer, because that protocol, not the file format, is what decides whether DuckDB, Spark, Trino, Snowflake, ClickHouse and PyIceberg can all work on one table safely.
| Spec | Iceberg REST catalog OpenAPI (open-api/rest-catalog-open-api.yaml in apache/iceberg) |
| Catalog in Loams' design | Lakekeeper, Lakekeeper 0.13.6 in the House's tests |
| Client in Loams | RestCatalog in loams-house (fabric/crates/loams-house/src/catalog/rest.rs), a small JSON client of the spec |
| Decisions | D6 (Iceberg for tables, via Lakekeeper), D45 (other engines read through the REST catalog), D762 (every House table is Iceberg v2 in Lakekeeper), D571 (an external REST catalog beside Lakekeeper, proposed) |
| In Loams | Experimental House as a REST client · Planned external engines through Lakekeeper |
What the protocol is
The REST catalog is a JSON-over-HTTP API, specified as OpenAPI. Before it existed, each engine reached a catalog its own way: a Hive Metastore over Thrift, AWS Glue's API, a JDBC database. Each had its own idea of atomicity. The REST spec moved the commit logic to the server, so a client describes what it wants to change and the catalog decides if it may.
Configuration and prefixes
A client starts with GET /v1/config?warehouse=<name>. The answer has two required maps, defaults and overrides: properties the client applies before and after its own configuration, so an override wins. The OpenAPI file does not name a prefix field, but its routes all carry an optional {prefix} path segment, and the reference client reads prefix from these merged properties and inserts it after /v1/ on every later call. That is how one server hosts many warehouses. Recent versions of the spec also return an optional endpoints list naming which routes the server supports, and an idempotency-key-lifetime that says clients may send Idempotency-Key headers. The same properties can carry a namespace-separator for nested namespaces (below).
Namespaces and tables
Resources hang off /v1/{prefix}/namespaces:
GET,POSTonnamespacesto list and create;GET,HEAD,DELETEonnamespaces/{namespace};POST namespaces/{namespace}/propertiesto update properties.GET,POSTonnamespaces/{namespace}/tablesto list and create tables;POST …/registerto adopt an existing metadata file;POST /v1/{prefix}/tables/rename.GET namespaces/{namespace}/tables/{table}is loadTable;POSTto the same path commits;DELETEdrops, withpurgeRequestedsaying whether files go too.
Namespaces can nest. In a path, the levels of a nested namespace are joined by the unit separator by default, U+001F, percent-encoded as %1F; a server can advertise another separator as namespace-separator in /v1/config, and must still accept 0x1F.
loadTable and credential vending
loadTable returns a LoadTableResult: metadata-location, the full metadata (schemas, partition specs, sort orders, snapshots, refs), and a config map. The client then reads manifests and data files from the bucket itself.
Reading the bucket needs credentials, and handing every engine long-lived bucket keys is how lakehouses leak. So a client may send X-Iceberg-Access-Delegation with vended-credentials or remote-signing. With vending, the catalog returns short-lived storage credentials scoped to the table's location (in config, or in the newer storage-credentials list); with remote signing, the client asks the catalog to sign each storage request. Either way, access to data is decided where access to the table is decided.
Commits: requirements and updates
A commit is POST …/tables/{table} with a CommitTableRequest:
requirements: assertions about the table's current state, checked atomically by the catalog.assert-create,assert-table-uuid,assert-ref-snapshot-id(a branch such asmainstill points at the snapshot I started from),assert-current-schema-id,assert-last-assigned-field-id,assert-last-assigned-partition-id,assert-default-spec-id,assert-default-sort-order-id.updates: the changes, in order.add-snapshot,set-snapshot-ref,add-schema,set-current-schema,add-spec,set-default-spec,add-sort-order,set-properties,remove-snapshotsand more.
If every requirement holds, the catalog applies the updates, writes a new metadata file and swaps the pointer, and answers with the new metadata. If one fails, it answers 409 and nothing changes; the client reloads, rebases its change on the new state and tries again. A 500, 502 or 504 on commit is different: the spec calls the outcome CommitStateUnknown, and the client must not delete the files it wrote, because the commit may have landed. (A 503 means the service was not ready, so the client waits and retries.) POST /v1/{prefix}/transactions/commit commits several tables atomically, where the server supports it.
Authentication
The spec defines bearer tokens and an OAuth2 token endpoint, /v1/oauth/tokens, which the spec has deprecated in favour of an external identity provider. Lakekeeper takes tokens from an external identity provider and authorizes with OpenFGA.
Why Loams speaks it
Loams' rule is open formats in your bucket (see open formats). For analytics that means Iceberg (D6), and Iceberg is only open if other engines can find and commit the tables. D45 made that explicit: Loams tables are queried through Flight SQL and the native API, and by DuckDB, Trino, Spark or ClickHouse itself through the Iceberg REST catalog. There is no proprietary catalog in the path to lock you in. Lakekeeper is the catalog because it is a Rust REST catalog with credential vending and OpenFGA authorization, which matches the rest of Loams.
How Loams implements it
Loams House: a REST client
Loams House writes every table as an Iceberg v2 table (D762), and its catalog of record is a trait with three implementations: RestCatalog for an Iceberg REST catalog (Lakekeeper in production), LocalCatalog on SQLite for --single-node, and an in-memory one for tests.
RestCatalog is deliberately not iceberg-catalog-rest. That crate's update_table takes a TableCommit that only iceberg-rust's own transactions can build, and the House commits its own requirements and updates: DDL, appends with group commit, and compaction as Iceberg replace snapshots. So the House uses iceberg-rust's spec types (schemas, specs, metadata, requirements, updates), which serialize to the protocol's shapes, and a small HTTP client for the routes. It covers:
GET /v1/config?warehouse=, honouring aprefixfromoverridesordefaults;- namespaces: list, create, load, drop, nested with U+001F;
- tables: list, create, load, drop (with
purgeRequested=false), rename, register; - commits with
requirementsandupdates, mapping a 409 to a conflict that the writer rebases and retries.
The tests run the same suite (nested namespaces, tables that live and move, commits that are compare-and-set) against the in-memory catalog, the SQLite catalog and, when LOAMS_HOUSE_LAKEKEEPER_URL names one, a real Lakekeeper.
What it does not do: it sends no X-Iceberg-Access-Delegation, because the House's workers never hold credentials. Reads go through house-cache, which signs upstream requests with the namespace's credentials inside the front's process. It does not use multi-table transactions.
The compose stack in Loams 0.1.0 runs the House with --single-node, so on a default install the House uses its SQLite catalog, not a REST one. That is why this is experimental rather than available.
Other engines through Lakekeeper: the plan
The plan in design 08 and design 49 is one Lakekeeper warehouse per cluster, a namespace ns_<id> per Loams namespace, and every table that Loams writes registered there, so Spark, Trino, DuckDB and ClickHouse read the same snapshots. The Fabric stack that would deploy Lakekeeper (FL1) is not built. D571 proposes accepting an external REST catalog, such as Cloudflare's Basin Catalog, beside Lakekeeper.
Design 58 adds a Postgres path: CDC into Iceberg first (D1052), then, for opted-in tables, the Loams Postgres pageserver writing Parquet and committing an Iceberg snapshot through Lakekeeper tagged with the LSN (D1053).
The native Iceberg sink connector in loams-connector-lakehouse is held back by a decision: iceberg-rust 0.10.1 needs Arrow 58 and the workspace is on 59, and we chose to wait for an iceberg-rust release on Arrow 59 rather than link two Arrows.
Gotchas
- Not every 409 says
CommitFailedException. The spec's error body is{"error": {"type", "message", "code"}}, and Lakekeeper 0.13.6 does not nameCommitFailedon a failed commit requirement. The House treats every 409 on a commit as a failed requirement. - A 500, 502 or 504 on commit is not a failure. Cleaning up after it can delete the files of a commit that landed. Reload and check.
- Use the prefix when the server sets it. Clients that skip
/v1/configcall the wrong paths. - Nested namespaces are encoded, not slashed. U+001F, percent-encoded, in one path segment.
- The catalog writes the metadata file. With a REST catalog the client sends updates, and the catalog writes the new
metadata.json, so the catalog's storage profile must cover the table's location. - Vended credentials expire. Long scans must refresh them; the spec has an endpoint for loading credentials again.
- Requirements are the concurrency control. A writer that sends updates with no
assert-ref-snapshot-idcan overwrite a concurrent commit. Always assert the state you started from.
Read next
- Apache Iceberg, iceberg-rust and Lakekeeper, the format and the projects.
- ClickHouse HTTP interface, the House's own front door.
- Arrow Flight SQL and ADBC, the other way analytics tools reach Loams.