---
title: Boards and schema
description: How a board's schema comes to be, inferred from data or declared up front, plus column widening and the explicit lifecycle calls.
---

A board is a dynamic, user-defined table: the multi-tenant unit.

## Boards are created on first write

```ts
await engine.insert("acme_roadmap", { id: "task-1", state: "active", title: "Ship it" });
```

Nothing created `acme_roadmap` first. Under the default `autoCreate: "on-write"`, a write to an unknown board creates it. That also means a typo'd board id silently creates a permanent board rather than failing.

`autoCreate: "never"` on `open()` turns that off: a write to a board that doesn't exist is refused with `BoardNotFound`, and `createBoard` becomes the only way one comes into being. A read never creates a board, under either setting.

## Two ways to define a schema

|                                           | Inferred (`columns: "infer"`, default)                        | Declared (`createBoard` with `columns`)                                                  |
| ----------------------------------------- | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| Column type set by                        | The first write that mentions the column                      | `createBoard`, before any row exists                                                     |
| Typo protection                           | None: `staus` quietly becomes its own column next to `status` | Full, once `strict: true` is also set                                                    |
| Board still works with nothing configured | Yes, this is the default                                      | `createBoard("billing")` with no `columns` still creates the board, inferring from there |

Under the default `columns: "infer"`, the first write that mentions a column decides its type, and later writes are checked against it rather than re-deciding it. That's what makes a board schemaless.

`createBoard` is the alternative: declare columns, with types, before any row exists.

```ts
await engine.createBoard("billing", {
  columns: { amount: "double", currency: "text", paid: "boolean", due: "timestamp" },
  strict: true,
});
```

`columns` types each column up front, so the first value to arrive never decides it. `strict: true` goes further: it makes writes to `billing` refuse any column not on that list, on every node in the cluster. See below for what that means and where the bit lives.

## Column types are inferred, once

A value that doesn't fit an existing column's type is refused, not accommodated:

```ts
const conflict = await engine.insert("acme_roadmap", {
  id: "task-9",
  state: "active",
  priority: "N/A", // priority is already bigint
});
conflict.isErr() && conflict.error._tag; // "ColumnTypeConflict"
```

The recovery is explicit: widen the column, then retry the write.

```ts
await engine.widenColumn("acme_roadmap", "priority", "text");
await engine.insert("acme_roadmap", { id: "task-9", state: "active", priority: "N/A" });
```

## Per-board strict, or engine-wide

`strict: true` on `createBoard` is a property of that one board, not of the process that set it: the bit is stored in the catalog (`lakefront_boards`), read with a 30-second-TTL cache, so a write hitting a different node still sees the same refusal. A write naming an undeclared column on a strict board fails with `UnknownColumn`:

```ts
await engine.insert("billing", { id: "b1", state: "active", amount: 5, memo: "late fee" });
// UnknownColumn: "memo" was never declared, and this board is strict
```

The engine-wide equivalent is `columns: "declared"` on `open()`: every board on this engine refuses undeclared columns, not just ones created with `strict: true`. A board's own `strict` bit and the engine's `columns` option both gate the same check. Either is enough to trigger it.

## Column type vocabulary

`ColumnType` is the public vocabulary `createBoard`, `addColumn`, and `widenColumn` accept, lowercase:

| Name        | Underlying type | Note                                                                                                                                                            |
| ----------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `boolean`   | `BOOLEAN`       |                                                                                                                                                                 |
| `tinyint`   | `TINYINT`       |                                                                                                                                                                 |
| `smallint`  | `SMALLINT`      |                                                                                                                                                                 |
| `integer`   | `INTEGER`       |                                                                                                                                                                 |
| `bigint`    | `BIGINT`        |                                                                                                                                                                 |
| `hugeint`   | `HUGEINT`       |                                                                                                                                                                 |
| `float`     | `FLOAT`         |                                                                                                                                                                 |
| `double`    | `DOUBLE`        |                                                                                                                                                                 |
| `text`      | `VARCHAR`       |                                                                                                                                                                 |
| `timestamp` | `TIMESTAMP`     | Sits outside the widening lattice below. Inference never produces it and `widenColumn` can't target it, so a `timestamp` column's type is fixed at declaration. |

## Adding and widening columns

A column arrives implicitly the first time a write names it, or explicitly ahead of time:

```ts
await engine.addColumn("acme_roadmap", "assignee", "text");
```

`addColumn` is required on a strict board (per-board or engine-wide `columns: "declared"`), since inference is off and nothing else can introduce a column. Under `"infer"` it's still useful, for fixing a column's type up front instead of letting the first value passing through decide it.

`widenColumn(boardId, column, to)` moves a column's type deliberately, and only ever upward. A request that would narrow a column is refused, so nothing already written can stop fitting. The type lattice, narrowest to widest:

```
BOOLEAN → TINYINT → SMALLINT → INTEGER → BIGINT → HUGEINT → FLOAT / DOUBLE → VARCHAR
```

(`timestamp` is not on this lattice at all. See above.) VARCHAR sits at the top: any value can be rendered as text, so it's always a valid destination and never a valid source.

| Widening                 | Cost                                                                                                                          |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------- |
| Within the numeric chain | Metadata-only `ALTER COLUMN`, independent of row count                                                                        |
| To `VARCHAR` (text)      | Rewrites every row. DuckLake has no native promotion into VARCHAR, so expect it to cost time proportional to the board's size |

Both run atomically. A replica syncing past a widening also pays an inline index rebuild, described in [replica sync](/concepts/replicas).

Widening is irreversible by construction. A request that would move a column back down the lattice is refused:

```ts
const narrowed = await engine.widenColumn("acme_roadmap", "priority", "tinyint");
narrowed.isErr() && narrowed.error._tag; // "ColumnTypeConflict": that would narrow the column
```

## Every row has BASE_COLUMNS

```ts
import { BASE_COLUMNS } from "lakefront";
// { id: "VARCHAR", updated_at: "TIMESTAMP", state: "VARCHAR" }
```

`id`, `updated_at`, and `state` exist on every board table and are the system's contract rather than inferred. A value that disagrees with their type is reported. They cannot be redeclared through `createBoard`'s `columns`. Everything else is user-defined.

The engine sets `updated_at` on every insert, upsert, and update: the UTC time it accepted the write. Rows written in one call or one transaction share a single timestamp. A value you pass for `updated_at` is overwritten, so treat the column as read-only.

Column names starting with `_` are reserved for the engine's own columns (the version stamp `upsert` and `compact` read, for instance), and a write or declaration naming one is refused with `ReservedColumnName`.

```ts
await engine.insert("b", { id: "x", state: "active", _v: 1 });
// ReservedColumnName: the "_" prefix names the engine's own columns
```

## Explicit lifecycle

```ts
await engine.createBoard("acme_archive"); // BoardExists if it's already there
await engine.hasBoard("acme_archive"); // Result<boolean, EngineError>
await engine.boards(); // every board in the lake
await engine.truncateBoard("acme_archive"); // empties it, keeps its columns
await engine.dropBoard("acme_archive"); // removes it and every row, everywhere
```

Use `createBoard` to create boards explicitly, declare column types, or enable strict schema checks.

`dropBoard` and `truncateBoard` reach every other node in the cluster with no message passing. The change rides the same WAL tombstone mechanism writes do, so a syncing replica discovers the board is gone or emptied the next time it asks for what changed. For how that propagation works, see [write paths](/concepts/write-paths).
