---
title: Truth store
description: Why DuckLake serves as Lakefront's source of truth instead of a plain database, and what that costs in inlining, snapshot retention, and per-lake namespacing.
---

DuckLake is what Lakefront calls the lake: a lakehouse format made of Parquet data files plus a Postgres catalog that holds the metadata describing them, and, for small mutations, the row data itself, inlined directly into catalog tables instead of spilling to Parquet. Every board is one table in this catalog; every committed change to it advances a snapshot.

## Lakehouse format over a database

A database as truth couples storage to one engine's format and one engine's availability. DuckLake instead separates the two: bulk data lives as Parquet in commodity object storage, and only the metadata (table definitions, column types, which files belong to which snapshot) lives in Postgres.

That split is what makes a server node stateless with respect to correctness: a replica file can be deleted at any moment and rebuilt from the lake, because the lake, not the replica, is what any node trusts.

It is also what makes storage cheap at scale: Parquet in object storage costs a fraction of the same bytes held live in a database's own storage engine. And it gives every commit snapshot semantics for free: `table_changes(lo, hi)` is a range scan over an ordered sequence of snapshot ids assigned at commit, so a replica's sync question ("what changed since I last looked") is answerable without any separate change-tracking machinery.

That is also why there is no external WAL writer, no Kafka, and no hand-built flush pipeline in this codebase where a comparable system might have one: DuckLake's own commit ordering already gives an ordered, replayable log.

What it costs: every commit is still a real transaction against the catalog, and DuckLake's catalog commit is serialized globally. See [write paths](/concepts/write-paths) for the measured throughput ceiling that forces the WAL write path to exist at all.

A lakehouse also accumulates the artifacts of correctness (superseded snapshots, small files, orphaned data) as a byproduct of normal operation. That debt does not pay itself down, which is what `maintain()` exists for.

## Inlining

`inliningRowLimit` controls the row count below which a mutation stays in the Postgres catalog instead of being written to Parquet:

```ts title="core/options.ts"
export interface OpenOptions {
  readonly postgres: string;
  readonly data: string;
  readonly s3?: S3Config;
  readonly namespace?: string;
  readonly inliningRowLimit?: number;
  // ...
}
```

The default is 10,000 rows. Raising it keeps larger batches in the catalog; lowering it sends smaller batches to Parquet.

## Where the data lives: `data` and object storage

`data` is where DuckLake writes the Parquet files that inlining doesn't absorb. Locally this is a plain directory on disk. `open()` creates it if it doesn't exist. In a deployment it points at `s3://` (or an S3-compatible endpoint such as MinIO), configured through `S3Config`:

```ts title="core/types.ts"
export interface S3Config {
  readonly endpoint: string;
  readonly keyId: string;
  readonly secret: string;
  readonly ssl?: boolean;
  readonly region?: string;
}
```

The engine installs the `httpfs` extension and registers an S3 secret only when `s3` is present in `OpenOptions`; without it, `data` is an ordinary filesystem path DuckDB writes to directly. The infra compose snippet that stands up the catalog Postgres and MinIO for local development is owned by the [quickstart guide](/guides/quickstart).

## Snapshots and time

Every committed change to the lake (a row write, a schema change, a board drop) advances the snapshot sequence by one. That ordering is what a replica's sync and the change feed both rely on: `snapshotBounds` asks the catalog for the range of snapshot ids above a watermark in one round trip, and `table_changes` replays exactly that range.

Snapshots accumulate as long as nothing expires them, and so does everything they reference: superseded Parquet files, small files from a frequent flush cadence, orphaned data no live snapshot points to any more.

`maintain()` is the recipe against that: it expires old snapshots past a retention horizon, merges small adjacent files, and cleans up files nothing references. It is not automatic on every commit. It runs on its own cadence (the flusher's maintenance tick, by default hourly) and can also be invoked directly.

The retention horizon is also the boundary past which a replica can no longer sync incrementally and must rebuild instead. Under the `"wal"` write path that only bounds how far back time-travel reaches, since replicas sync from the WAL rather than from lake snapshots directly. The operational recipe for running `maintain()` and reading its effects lives in the [operations guide](/guides/operations).

## One lake per schema

`namespace` namespaces a lake's catalog tables inside Postgres, so more than one independent lake can share a single Postgres database without their table names colliding. It defaults to `"lakefront"` and needs setting only when more than one lake shares a Postgres. It is not the board id or the data path, purely a Postgres schema name interpolated directly into DDL.

Under the `"wal"` write path it is also the schema the WAL table lives in, and there it is checked against a strict identifier pattern before use, since the WAL's bootstrap runs that interpolation on every open.
