Skip to content
TidesDB

How Data Is Stored

How Data Is Stored

Understanding the physical layout helps when reading ANALYZE TABLE output or diagnosing performance. The storage mechanics below the column family, SSTables, compaction, and recovery, belong to the library and are covered in the TidesDB library manual.

Each table’s data lives in a column family, an independent LSM B+tree. Writes go to a skip-list memtable. When the memtable fills it becomes immutable and is flushed as a sorted SSTable, and compaction merges overlapping SSTables into higher levels while keeping the sorted invariant.

Keys

Row keys inside a column family carry a namespace prefix byte, 0x01 for data rows and 0x00 for metadata. The metadata namespace holds the auto-increment counter, under an AINC key as an 8-byte big-endian value; the table’s storage options, under an OPTS key as the JSON object the engine reads back at open; and the FTS aggregate counters (document count and word count for BM25 scoring). Because 0x00 sorts before 0x01, a data scan that seeks to 0x01 naturally skips all of them, and a scan looking for the last row is never handed a metadata key.

Primary-key bytes are encoded in a memcmp-comparable form. For a signed 32-bit integer the encoding flips the sign bit and stores the result big-endian, so -1 sorts before 0 and 0 before 1 under a plain byte comparison. The same principle covers the other numeric types and string collations. A table without an explicit primary key gets a hidden 8-byte big-endian row id from an atomic counter, recovered at open by seeking the last key in the column family.

Row values

Row values are a packed binary format. Each row begins with a 5-byte header, a magic byte 0xFE followed by the null bitmap size (2 bytes little-endian) and the field count (2 bytes little-endian) as of the write. That header is what makes ADD COLUMN and DROP COLUMN instant, because the deserializer can adapt to rows written under any prior schema. After the header comes the null bitmap, then each non-null field serialized with Field::pack(). On read, Field::unpack() restores the fields. A row written with fewer fields than the current schema, from before a column was added, fills the missing fields with their DEFAULT, and a row written with more fields, from before a column was dropped, has the extra data skipped. This is more compact than the raw record buffer, especially for VARCHAR and CHAR columns.

Secondary index entries

Every index other than the primary key lives in its own column family, and all three kinds put the primary key at the end of the entry key so the entry identifies its row. What differs is what leads the key and what, if anything, the value carries.

IndexKeyValue
Ordinarycomparable index-column bytes, then the comparable PK bytesone zero byte
Spatialthe Hilbert code of the geometry’s bounding-box centroid, then the PK bytesthe bounding box, as four doubles
Full-textthe term, then the PK bytesthe term frequency and the document’s word count, for BM25

An ordinary index keeps everything in the key, which is what lets it answer from the key alone. To resolve a lookup the engine seeks into the index CF, reads the key, splits off the trailing PK bytes, and does a point-get into the data CF. When the query needs only indexed columns and each is of a reconstructable type, integers, temporal types, or fixed CHAR/BINARY in binary or latin1, the row is decoded straight from the index key bytes and the data-CF point-get is skipped.

The other two carry a value because their key is lossy by design. A Hilbert code orders geometries for locality but does not describe them, so the bounding box in the value is what a spatial predicate is actually filtered against; see Spatial Indexes. A full-text entry needs per-document term statistics that the term and the row id do not carry; see Full-Text Search.