Loams v0.1.0 is out: run the whole stack yourself with Docker Compose. See the roadmap

Blog/The protocols Loams speaks

The Iceberg REST catalog: one pointer per table, over HTTP

The Iceberg REST catalog protocol is how every engine finds, reads and commits an Iceberg table. How /v1/config, loadTable, credential vending and commits with requirements and updates work, how Loams House already speaks it as a client to Lakekeeper, and the plan to make Loams tables readable by DuckDB, Spark, Trino and ClickHouse through it.

The Iceberg REST catalog: one pointer per table, over HTTP
On this page
  1. What the protocol is
  2. Configuration and prefixes
  3. Namespaces and tables
  4. loadTable and credential vending
  5. Commits: requirements and updates
  6. Authentication
  7. Why Loams speaks it
  8. How Loams implements it
  9. Loams House: a REST client
  10. Other engines through Lakekeeper: the plan
  11. Gotchas
  12. Read next

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.

SpecIceberg REST catalog OpenAPI (open-api/rest-catalog-open-api.yaml in apache/iceberg)
Catalog in Loams' designLakekeeper, Lakekeeper 0.13.6 in the House's tests
Client in LoamsRestCatalog in loams-house (fabric/crates/loams-house/src/catalog/rest.rs), a small JSON client of the spec
DecisionsD6 (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 LoamsExperimental 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, POST on namespaces to list and create; GET, HEAD, DELETE on namespaces/{namespace}; POST namespaces/{namespace}/properties to update properties.
  • GET, POST on namespaces/{namespace}/tables to list and create tables; POST …/register to adopt an existing metadata file; POST /v1/{prefix}/tables/rename.
  • GET namespaces/{namespace}/tables/{table} is loadTable; POST to the same path commits; DELETE drops, with purgeRequested saying 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 as main still 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-snapshots and 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.

The catalog holds one pointer per table and checks the commit's requirements against it. Data and metadata files go to the bucket directly; only the pointer swap goes through the catalog.

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 a prefix from overrides or defaults;
  • namespaces: list, create, load, drop, nested with U+001F;
  • tables: list, create, load, drop (with purgeRequested=false), rename, register;
  • commits with requirements and updates, 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 name CommitFailed on 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/config call 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-id can overwrite a concurrent commit. Always assert the state you started from.

More from the blog