> ## Documentation Index
> Fetch the complete documentation index at: https://docs.chaintable.com/llms.txt
> Use this file to discover all available pages before exploring further.

# How tables work

> Understand BlockDB's table layers, subtables, and block progress, and how its four table types organize current records and history.

BlockDB provides read and write access to Chaintable's online tables. The table type determines what `id` represents, how a write affects current records, and how historical reads find the required version.

| Type            | Suitable data                                         | How changes are represented                      |
| --------------- | ----------------------------------------------------- | ------------------------------------------------ |
| **Normal**      | Asset information, configuration, lookup dictionaries | One current record per `id`                      |
| **Block Event** | Transfers, transactions, contract events              | Each event has its own `id` and block            |
| **Block State** | Pool reserves, contract state                         | Versions of the same business `id` across blocks |
| **Time**        | Prices and time-sampled values                        | Values for the same `id` over wall-clock time    |

## L1, L2, and subtables

BlockDB has two table layers. **L1 and L2 are storage abstraction layers**, unrelated to blockchain scaling layers.

* **L1 tables** provide fields, primary keys, indexes, and row reads and writes. Normal Tables use this layer directly.
* **L2 tables** add block or time semantics on top of L1. Block Event, Block State, and Time Tables are L2 tables: they translate each logical write into updates to a set of L1 tables.

These system-managed tables are called **subtables**. They store business data, historical versions, or processing progress so that the system can answer three questions: what is the current value, what was the value at a given block, and which blocks have been processed?

For example, the Block State Table `demo.pool_reserves.eth` uses these subtables:

| Subtable ID                       | Stored data                                         | Purpose                                                    |
| --------------------------------- | --------------------------------------------------- | ---------------------------------------------------------- |
| `demo.pool_reserves.eth`          | Latest state for each business `id`                 | Labeled **latest** in the UI; used for current-value reads |
| `demo.pool_reserves.eth._archive` | Historical versions of objects across blocks        | Read state at a past block                                 |
| `demo.pool_reserves.eth._height`  | Ranges of processed block heights                   | Identify coverage and gaps                                 |
| `demo.pool_reserves.eth._bundle`  | Data summaries and coverage counts per block bundle | Compare data by range and synchronize changes              |
| `demo.pool_reserves.eth._write`   | Asynchronous write jobs and their status            | Track batch-write completion                               |

A **block bundle** is a fixed range of 1,000 blocks, numbered in height order: bundle `N` covers heights `(N-1) × 1000 + 1` through `N × 1000`. Batch writes and analytics synchronization both work in these units.

Subtables vary by table type. A Block Event Table's data table shares its ID and is labeled **event**. Time Tables have **latest** and **archive** but do not need block-progress tables. `latest` and `event` describe the data tables' roles; they are not suffixes to append to a table ID.

Use the SDK interface for the table type for routine reads and writes, so BlockDB can maintain its subtables together. Writing directly to a subtable can leave business data inconsistent with history or progress. Subtables are primarily useful for querying data and inspecting processing status.

## Normal Tables: one current record per ID

A Normal Table uses `id` as its primary key. Writing a new `id` inserts a record; writing an existing `id` updates that record. Previous values are not retained automatically.

For example, the `id = 'usdc'` record in `demo.asset_notes` can store a token symbol and a business description. After updating the description, reading that ID returns the current content. To track changes, choose a table type that retains history or design your own history records.

## Block Event Tables: records of individual events

A Block Event Table associates each event with `block_height`, `block_id`, and `block_timestamp`. The underlying records are distinguished by block height and event `id` together, so events within the same block must use different IDs.

For example, if an address receives several USDC transfers in a day, each transfer needs its own event ID. A transaction and log position can usually identify a transfer. Using only the recipient address would cause transfers to that address within the same block to conflict.

Event tables retain individual occurrences. An object's current balance must be calculated from events or stored separately in a state table; it cannot be read directly from a single transfer record.

## Block State Tables: current state and historical versions

A Block State Table's `id` identifies the object being tracked. For pool reserves, use the pool address and write a new state for that ID when reserves change in another block.

When a block is written, BlockDB saves the object's **archive** version and maintains **latest**. In a historical record, `original_id` points to the business ID, while the record itself has an independent `id`. A write updates latest only if its block height is at least as high as the existing latest version. Backfilling older blocks therefore does not overwrite newer state with older values.

The two read methods use different data paths:

* `StateTable(table_id).get(id)` reads latest directly, returning the object's latest stored state.
* `StateTable(table_id, block_id).get(id)` checks block coverage, then finds the latest archive version at or before the target block.

For example, after reserves update at height `25939930`, reads at subsequent processed blocks return those reserves until the next change. A historical read does not require the object to have changed in the exact target block, nor does the table need to store another copy for every unchanged block.

## Block data and processing progress

Business records show what data a block produced. A block with no USDC transfers may have been checked and had no matching events, or it may not have been processed at all. BlockDB therefore tracks block coverage separately.

For a single-block write, BlockDB performs these steps:

1. Resolve the height and timestamp from the block ID and validate the write range.
2. Write event records or maintain the state table's latest and archive.
3. Update block bundle summaries and merge the height into the processed ranges.

Business data and progress updates complete in a single commit. Even an empty record list advances coverage; subscription notifications are sent after the commit.

**Start height** is the first block a table covers. Data below it is outside the table's processing range, and writes below it are rejected.

**Consensus height** is the highest block processed continuously from the table's start height. It describes the table's data coverage, not blockchain finality, and is not the largest `block_height` in its records.

Suppose the start height is `25939930`, and blocks `25939930` and `25939932` have been processed, but `25939931` has not. The latest record may come from `25939932`, while consensus height remains at `25939930`. It can advance only after the gap is filled.

A state read without a block ID does not fall back to consensus height. An event read by ID defaults to the record at the highest block height for that ID. To compare objects or tables at the same block, explicitly pass a block ID and confirm that every table covers it continuously from its own start height. A block above consensus height is rejected. Reading latest directly does not prove that the intervening blocks are complete.

See [Advanced features](/guides/tables/advanced) for setting the start height, checking consensus height, and inspecting asynchronous write jobs.

## Time Tables: values over time

A Time Table organizes state by wall-clock time using the fixed fields `id`, `time_at`, and `value`. It suits numeric series such as prices and is not tied to one chain's blocks.

Time Tables also maintain latest and archive separately. `TimeTable(table_id).get(id)` reads the latest value. When `time_at` is specified, the server first rounds the time up to the corresponding bucket boundary, then finds the most recent historical record at or before that boundary. It does not interpolate values.

Time buckets group nearby samples into the same interval. Each object has one value per bucket, and repeated writes to that bucket update the value. As data ages, bucket granularity changes from minutes to hours and days to reduce long-term storage. Older data is therefore useful for trends, but cannot reconstruct every price change within a bucket. Specifying a time to the second does not provide a second-by-second historical snapshot.

See [Read and write data](/guides/tables/read-and-write) for examples for each table type.
