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 LSM-tree with its own SSTables and levels. Writes go to a skip-list memtable, which in TidesDB 10 is database-level and shared by every column family, as is the write-ahead log. When the memtable fills it becomes immutable and is flushed to sorted SSTables in each column family it holds keys for, 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 start value set by AUTO_INCREMENT=N in CREATE TABLE or ALTER TABLE, under an AINC key as an 8-byte big-endian value so it survives a restart even when the table has no rows to recover the counter from (the running counter itself lives in memory and is seeded at open from the last row), and the FTS aggregate counters (document count and word count for BM25 scoring). Metadata sorts before data, so a data scan that seeks to 0x01 naturally skips them.

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. After the header comes the null bitmap, then each non-null field serialized with Field::pack(), in table order. On read, Field::unpack() restores the fields. Nothing in the row names its columns, so an old row decodes by position. A row written with fewer fields than the current schema, from before columns were appended, fills the missing fields and their null bits from their DEFAULT, which is what makes an appended ADD COLUMN instant. A change that moves an existing column or its null bit, such as DROP COLUMN or adding a column before others, rewrites the table instead (see Online DDL). This is more compact than the raw record buffer, especially for VARCHAR and CHAR columns. On an ENCRYPTED table the whole packed row is then wrapped in an encryption envelope before it is stored.

Secondary index entries

A regular secondary index entry lives in its own column family. The key concatenates the comparable index-column bytes with the comparable primary-key bytes, and the value is a single zero byte, so all the information is in the key. 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, YEAR, DATE, DATETIME or TIMESTAMP without fractional seconds, 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. TIME is not reconstructable. This covering read applies only to tables with an explicit primary key.

Because the key carries the primary key as its suffix, two rows with the same value in a UNIQUE secondary index store two different keys. So every write that creates a non-NULL value in a UNIQUE secondary index also writes a sentinel key, the table’s data CF name, a NUL byte, one byte of index number, and the comparable value, into a reserved column family named __tidesdb_uniq that every table shares. Two transactions writing the same value then collide on that one key, and at SNAPSHOT isolation or higher the commit-time conflict check lets only the first committer through. The sentinel is skipped along with the uniqueness check when the session sets tidesdb_skip_unique_check. The sentinel is never read for the uniqueness check itself, which stays with the index probe, and a table’s sentinels are purged by prefix when it is dropped, truncated, or renamed. Internal families whose names begin with __tidesdb are left out of the per-family listing in SHOW ENGINE TIDESDB STATUS.