Lakehouse – Phase 1 Metadata Catalog
Every lakehouse has two layers: data files (Parquet files containing actual rows) and metadata (which files exist, what columns they have, what value ranges each file contains). The metadata layer is what separates a lakehouse from a pile of Parquet files. Apache Iceberg stores this metadata as a tree of manifest files (JSON/Avro) on object storage. DuckLake takes a different approach: it stores metadata in a relational database (Postgres, MySQL, or SQLite). This is not just a different storage location — it fundamentally changes how the entire system works.
In this series, we build a small lakehouse engine step by step in Go to understand how open table formats work under the hood. The approach mirrors DuckLake’s architecture: Postgres for metadata, Parquet for data, and a clean separation between the two.
Here is the roadmap for the phases to come:
- Phase 1: Metadata catalog
- Phase 2: Parquet data files
- Phase 3: Ingest pipeline
- Phase 4: Scanning and query
- Phase 5: Snapshots and time travel
- Phase 6: Schema evolution
- Phase 7: Deletes and updates
- Phase 8: Storage and access
Full Source Code
The code referenced in this post can be found in https://gitlab.com/kimserey.lam/lake-learn.
Why a Database Instead of Manifest Files (catalog/store.go)
In Iceberg, metadata is a tree of files: a metadata.json points to a manifest list, which points to manifests, which list individual data files with their statistics. Every query starts by downloading and parsing this chain of files.
DuckLake replaces this entire manifest tree with tables in a relational database. The “which files should I scan?” question becomes a SQL query with JOINs and WHERE clauses. The database handles indexing, concurrency, and atomic commits natively.
The consequences cascade through every part of the system:
File pruning becomes SQL. Instead of parsing manifest files to find which data files might match a WHERE clause, the engine queries the stats table: WHERE max_value >= '100'.
Atomic commits are free. Iceberg achieves atomicity by writing new manifest files and performing an atomic pointer swap (rename). With a database, atomicity is just BEGIN and COMMIT.
Schema history is queryable. Want to know what columns existed at snapshot 5? That is a SQL query with a snapshot filter.
The tradeoff is real. You now depend on a running database instance. Iceberg can operate with just a filesystem.
In DuckLake’s C++ source, DuckLakeMetadataManager (src/storage/ducklake_metadata_manager.cpp) handles all interactions with the metadata database.
Our Go equivalent wraps a Postgres connection pool:
1
2
3
4
5
6
7
type MetadataStore struct {
pool *pgxpool.Pool
}
func NewMetadataStore(pool *pgxpool.Pool) *MetadataStore {
return &MetadataStore{pool: pool}
}
All write operations use a WithTransaction method that accepts a closure. This pattern ensures the transaction is always committed or rolled back:
1
2
3
4
5
6
7
8
9
10
11
12
func (s *MetadataStore) WithTransaction(ctx context.Context, fn func(tx pgx.Tx) error) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
defer tx.Rollback(ctx)
if err := fn(tx); err != nil {
return err
}
return tx.Commit(ctx)
}
The Eight Metadata Tables (catalog/schema.go)
The metadata database contains eight tables. Together they describe the complete state of the lakehouse at any point in time.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
┌─────────────────────────────────────────────────────────────────────┐
│ METADATA DATABASE (Postgres) │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌──────────────────┐ │
│ │ ducklake_schema │───▶│ ducklake_table │───▶│ ducklake_column │ │
│ │ (namespaces) │ │ (table defs) │ │ (column defs) │ │
│ └─────────────────┘ └────────┬────────┘ └──────────────────┘ │
│ │ │
│ ┌─────────────▼──────────────┐ │
│ │ ducklake_data_file │ │
│ │ (Parquet file registry) │ │
│ └─────────────┬──────────────┘ │
│ │ │
│ ┌───────────────────┼───────────────────┐ │
│ ▼ ▼ │
│ ┌───────────────────────────┐ ┌──────────────────────────┐ │
│ │ ducklake_file_column_stats│ │ ducklake_delete_file │ │
│ │ (min/max/null) │ │ (positional deletes) │ │
│ └───────────────────────────┘ └──────────────────────────┘ │
│ │
│ ┌─────────────────────┐ ┌─────────────────────────────┐ │
│ │ ducklake_snapshot │───▶│ ducklake_snapshot_changes │ │
│ │ (version history) │ │ (change descriptions) │ │
│ └─────────────────────┘ └─────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
ducklake_snapshot
Version history. Each mutation creates a new snapshot with a monotonically increasing ID. The next_catalog_id and next_file_id fields are counters used to allocate unique IDs for new objects.
1
2
3
4
5
6
7
CREATE TABLE IF NOT EXISTS ducklake_snapshot (
snapshot_id BIGSERIAL PRIMARY KEY,
snapshot_time TIMESTAMPTZ NOT NULL DEFAULT now(),
schema_version BIGINT NOT NULL DEFAULT 0,
next_catalog_id BIGINT NOT NULL DEFAULT 1,
next_file_id BIGINT NOT NULL DEFAULT 1
)
ducklake_table
Table definitions with snapshot-based visibility. Setting end_snapshot means the table was dropped.
1
2
3
4
5
6
7
CREATE TABLE IF NOT EXISTS ducklake_table (
table_id BIGINT PRIMARY KEY,
schema_id BIGINT NOT NULL,
name TEXT NOT NULL,
begin_snapshot BIGINT NOT NULL,
end_snapshot BIGINT
)
ducklake_column
Column definitions with permanent field IDs for schema evolution. A column’s field_id never changes even if the column is renamed. Old name rows get end_snapshot set; new name rows get the same field_id with a new begin_snapshot.
1
2
3
4
5
6
7
8
9
10
11
CREATE TABLE IF NOT EXISTS ducklake_column (
column_id BIGINT PRIMARY KEY,
table_id BIGINT NOT NULL,
field_id BIGINT NOT NULL,
column_order INT NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL,
nullable BOOLEAN NOT NULL DEFAULT true,
begin_snapshot BIGINT NOT NULL,
end_snapshot BIGINT
)
ducklake_data_file
Registry of every Parquet data file. Each file is associated with a table and a snapshot range.
1
2
3
4
5
6
7
8
9
CREATE TABLE IF NOT EXISTS ducklake_data_file (
file_id BIGSERIAL PRIMARY KEY,
table_id BIGINT NOT NULL,
path TEXT NOT NULL,
record_count BIGINT NOT NULL,
file_size_bytes BIGINT NOT NULL,
begin_snapshot BIGINT NOT NULL,
end_snapshot BIGINT
)
ducklake_file_column_stats
Per-file, per-column statistics: minimum value, maximum value, and null count. This is the table that makes file pruning possible.
1
2
3
4
5
6
7
8
CREATE TABLE IF NOT EXISTS ducklake_file_column_stats (
file_id BIGINT NOT NULL,
field_id BIGINT NOT NULL,
null_count BIGINT NOT NULL DEFAULT 0,
min_value TEXT,
max_value TEXT,
PRIMARY KEY (file_id, field_id)
)
ducklake_delete_file
Tracks positional delete files. When rows are deleted from a data file, a small Parquet file is written listing the deleted row positions.
1
2
3
4
5
6
7
8
9
CREATE TABLE IF NOT EXISTS ducklake_delete_file (
delete_file_id BIGSERIAL PRIMARY KEY,
table_id BIGINT NOT NULL,
data_file_id BIGINT NOT NULL,
path TEXT NOT NULL,
delete_count BIGINT NOT NULL,
begin_snapshot BIGINT NOT NULL,
end_snapshot BIGINT
)
The Go implementation creates all eight tables through InitializeSchema, which iterates over the DDL definitions with IF NOT EXISTS so it is safe to call on an already-initialized database.
Snapshot Visibility: The Universal Filter
Nearly every metadata query includes the same filter pattern:
1
WHERE begin_snapshot <= $1 AND (end_snapshot IS NULL OR end_snapshot > $1)
This is the mechanism behind time travel. Every metadata row has a lifespan defined by begin_snapshot (when it was created) and end_snapshot (when it was retired, or NULL if still active). A query at snapshot N sees only rows whose lifespan includes N.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Snapshot timeline:
0 1 2 3 4 5 6 (snapshot IDs)
│ │ │ │ │ │ │
├────┤ │ │ │ │ │ file_001: begin=0, end=1 (replaced)
│ ├────┴────┴────┤ │ │ file_002: begin=1, end=4 (compacted)
│ │ ├────┴────┤ file_003: begin=4, end=6
│ │ ├─────────┴─────────┴── file_004: begin=2, end=NULL (still active)
Query at snapshot 3:
file_001: begin=0 <= 3, end=1 > 3? NO → invisible
file_002: begin=1 <= 3, end=4 > 3? YES → visible
file_003: begin=4 <= 3? NO → invisible (not yet created)
file_004: begin=2 <= 3, end=NULL → visible
This design means that dropping a table never deletes metadata rows. It sets end_snapshot on the table row, its columns, and its data files. The data remains accessible via time travel at older snapshots.
In Go, the filter is defined as a reusable constant:
1
const visibilityFilter = `begin_snapshot <= $1 AND (end_snapshot IS NULL OR end_snapshot > $1)`
Every metadata query interpolates this fragment:
1
2
3
4
5
6
7
8
9
10
func (s *MetadataStore) GetTable(ctx context.Context, snap int64, schemaID int64, name string) (*TableDef, error) {
var td TableDef
err := s.pool.QueryRow(ctx, `
SELECT table_id, schema_id, name, begin_snapshot, end_snapshot
FROM ducklake_table
WHERE schema_id = $2 AND name = $3 AND `+visibilityFilter,
snap, schemaID, name,
).Scan(&td.TableID, &td.SchemaID, &td.Name, &td.BeginSnapshot, &td.EndSnapshot)
// ...
}
The Type System (catalog/types.go)
The catalog defines the logical types supported by the lakehouse. These map to DuckLake’s type system in src/include/common/ducklake_types.hpp:
1
2
3
4
5
6
7
8
9
10
11
type LogicalType int
const (
TypeInteger LogicalType = iota // 32-bit signed integer
TypeBigInt // 64-bit signed integer
TypeFloat // 32-bit IEEE 754
TypeDouble // 64-bit IEEE 754
TypeVarchar // variable-length string
TypeBoolean // true/false
TypeTimestamp // timestamp with timezone
)
Every column gets a unique field ID at creation time. Field IDs are written into Parquet files as schema metadata, and they are what the reader uses to map file columns to catalog columns. This is the key to schema evolution:
1
2
3
4
5
6
7
8
9
10
type FieldID int64
type ColumnDef struct {
FieldID FieldID
Name string
Type LogicalType
Nullable bool
BeginSnapshot int64
EndSnapshot *int64 // nil = still active
}
Metadata rows with optional end_snapshot use *int64 in Go. A nil pointer means NULL (the row is still active). This maps cleanly to Postgres NULL semantics.
Comparison: Manifest Files vs Database Catalog
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Iceberg scan planning: DuckLake scan planning:
1. Read metadata.json pointer 1. SELECT df.path, cs.min_value,
2. Fetch manifest-list.avro cs.max_value
3. Parse manifest list → manifest paths FROM ducklake_data_file df
4. Fetch manifest-1.avro JOIN ducklake_file_column_stats cs
5. Parse manifest → file list + stats ON df.data_file_id = cs.data_file_id
6. Fetch manifest-2.avro WHERE df.table_id = $1
7. Parse manifest → more files AND df.begin_snapshot <= $2
8. In-memory: filter files by stats AND (df.end_snapshot IS NULL
9. Return pruned file list OR df.end_snapshot > $2)
AND cs.column_id = $3
Network round-trips: 4+ file fetches AND cs.max_value >= $4
Parse overhead: Avro deserialization
Network round-trips: 1 SQL query
Parse overhead: pgx result scanning
The database approach trades a dependency on a running database for fewer moving parts in every operation.
How Postgres Provides ACID for Metadata
The metadata database gives us four properties for free that manifest-based formats must engineer from scratch:
Atomicity. An INSERT that writes a Parquet file and registers it in the catalog happens inside a single Postgres transaction. If the metadata INSERT fails, the entire transaction rolls back.
Consistency. NOT NULL constraints prevent invalid metadata states. A manifest file has no such enforcement.
Isolation. Two concurrent transactions creating snapshots are serialized by Postgres. Iceberg achieves this with optimistic concurrency — write your manifest, attempt an atomic rename, retry if someone else committed first.
Durability. Once COMMIT returns, the metadata is persisted to Postgres’s WAL.