Skip to content

Fundamentals

Storage engines

How a database stores and finds data, from a plain file to LSM-trees, B-trees, and indexes.

Concise article — AI can help

Copy this prompt and paste it into your AI chat:

Help me study this article for practical system design.
Article: https://tech0ver.github.io/sysdes/fundamentals/storage-engines.md
Ask my goal and preferred depth: a quick recap, a walkthrough, or a deeper discussion. Use the article examples, flag your additions, and let me set the pace.

Or give your AI the article link with your own prompt:

Every system design has a database box, and inside it a storage engine decides which reads and writes are cheap and what a crash can lose. To choose a database for a workload, you need to know how engines store and find data, and what each choice costs.

A URL shortener maps a short code to a long URL. Creating a link stores the pair, and a redirect looks up the code. Redirects far outnumber new links. In SQLStructured Query LanguageThe standard language for querying and changing data in relational databases. A query says what data it needs, and the database chooses how to get it, such as which indexes to use.Read more: Wikipedia: SQL terms, a redirect is this query:

SELECT url
FROM links
WHERE code = ?;

The fastest store is a hash mapHash mapAn in-memory structure that finds a value by its key in constant time. It keeps no order, so it finds one key but cannot return a range of keys.Read more: Wikipedia: Hash table inside the app. It is enough for a throwaway prototype, but a restart wipes the data, and all of it must fit in the server’s RAMRandom-access memoryThe main memory of a server: fast, but lost on restart and much more expensive than disk. Data and indexes that fit in RAM are read without disk access.Read more: Wikipedia: Random-access memory. Issued links must keep working, so they go to disk.

Each link becomes a record: the code is its key, and the URL is its value. The simplest disk store is a file, and each new record is appended to its end as a line code,url. Appending is the cheapest write, since nothing is searched or rewritten. To change a value, append a new record with the same key; the last record for a key wins. A file that only grows at the end is a log, and most databases keep one inside.

Reading is the slow part. To find a key, scan the file from the start and keep the last match. For a few thousand records this is fine, but the scan grows with the file.

An append-only log file: writes go to the end, reads scan it A log file holds four records: abc with URL 1, xyz, abc with URL 2, and the new record qrs. A write appends a new record at the end. A read for abc scans the whole file from the start and keeps the last match, so the second abc record is the current value and the first one is old. DISK Log file abc,https://a.example/1 old value xyz,https://b.example abc,https://a.example/2 last match wins qrs,https://c.example new record APPEND only at the end READ abc scans the whole file from the start and keeps the last match

A file on one server’s disk survives restarts, but a second app server cannot read it. When several servers need the same data, it moves to a separate store that all of them call, for the same reason that servers stay stateless.

Every redirect scans the whole file, and the file keeps growing.

An index removes the scan. It is extra data, built from the records, that says where to find one.

The simplest index, a hash index, is a hash map in memory from each key to the byte offset of its latest record in the file. A write appends the record and stores its offset in the map. A read gets the offset and reads one record from disk, and often not even that, because the operating system keeps recently read parts of the file in memory.

A hash index in memory pointing into the log file on disk In memory, a hash map stores each key with the byte offset of its latest record: xyz at 24, abc at 46, qrs at 70. On disk, the log file holds four records; the old abc record at offset 0 stays in the file, but nothing points to it. A read of abc gets offset 46 from the map and reads that one record. MEMORY Hash map xyz → 24 abc → 46 qrs → 70 DISK Log file OFFSET 0 abc,https://a.example/1 old: nothing points here 24 xyz,https://b.example 46 abc,https://a.example/2 70 qrs,https://c.example READ A read: key → offset → one record

Any index has a price: it takes space, and every write must update it too. So a store indexes only what its queries need.

A hash index has its own limits. All keys must fit in RAM. The map is lost on restart and is rebuilt by reading the whole file. And a hash map keeps no order, so it finds one key but cannot return a range of keys.

Use a hash index for lookups by exact key while all keys fit in RAM. When they outgrow RAM, or queries need ranges, the index must keep its keys in order.

Reads are fast now, but the file keeps every old record and only grows.

Each change adds a record, and the old ones stay. To get the space back, the store splits the log into segments: when the current file reaches a size limit, it is closed and a new one starts. Closed segments never change.

In the background, compaction reads closed segments, keeps only the latest record for each key, and writes the result to a new segment. Reads keep using the old segments until the new one is ready, then switch to it, and the old files are deleted. The hash index now stores a segment and an offset for each key, and the switch moves its entries to the new places. This background work competes with requests for the disk.

An append-only file cannot erase a record in the middle, so a delete appends a tombstone: a record that marks the key as deleted. Compaction then drops the key. The tombstone itself must stay until no older segment holds the key, or the deleted value comes back. Until compaction gets there, deleted data is still on disk, which matters when users ask to remove their data for good.

Compaction merges closed segments Segment 1 holds abc with URL 1 and xyz. Segment 2 holds abc with URL 2 and a tombstone for xyz. The active segment takes new writes. Compaction merges segments 1 and 2 into a new segment that keeps only abc with URL 2: the latest record for each key. The deleted key xyz is gone, together with its tombstone. DISK Segment 1 closed abc,https://a.example/1 xyz,https://b.example Segment 2 closed abc,https://a.example/2 xyz,tombstone Active segment new writes qrs,https://c.example COMPACT abc,https://a.example/2 New segment the latest record for each key; xyz is gone with its tombstone compaction leaves it alone

Short links often expire. A read hides a record whose time has passed, and compaction removes it later, so expiry costs no write when it happens.

A server can crash in the middle of a write, and the file then ends with half a record. On restart, the store reads the file to rebuild its index and must not trust that half. To detect it, each record starts with its length and carries a checksumChecksumA small value computed from data to detect whether the data has changed. The writer stores it next to the data, and the reader computes it again. A mismatch means the data is incomplete or damaged.Read more: Wikipedia: Checksum of its contents. A record at the end of the file that is too short or fails the checksum is incomplete or damaged, and recovery drops it.

Records with a length and a checksum; the last one is cut off The end of the file holds records. Each record starts with its length and a checksum, then the data. The last record was cut off by a crash: it is too short and fails the checksum, so recovery drops it. End of the file LENGTH CHECKSUM DATA 24 9f3a xyz,https:… 24 9f3a abc,https:… 24 7c01 qr cut off by the crash: too short, fails the checksum RECOVERY DROPS IT

Even a complete record may not be on disk yet. A write to a file first lands in the operating system’s page cache in memory, and the system copies it to disk later. If only the store’s process crashes, the page cache keeps the data and it still reaches the disk. If the machine loses power or the operating system crashes, the data is gone. A write is durable only after a flush, such as fsync, forces it to disk and returns success.

A write goes through the page cache before it reaches the disk The store process writes to the page cache, which is memory owned by the operating system. A flush such as fsync copies the data to the data file on disk, and only then is the write durable. If only the process crashes, the data in the page cache survives; if the power fails, it is lost. MEMORY DISK Store process Page cache owned by the OS WRITE Data file durable FLUSH fsync process crash: the data survives power loss: the data is lost

For the shortener, a lost write is a short URL that a user has already shared and that now leads nowhere. So the store must choose when to say “ok”. It can answer after the flush: nothing it confirmed is lost, but each write waits for the disk, which is much slower than memory. Group commit makes this cheaper: the store collects the writes that arrive within a few milliseconds and flushes them together, so the disk does one flush instead of hundreds. The store can also answer before the flush and flush every second or so. Writes return faster, but a power loss erases the writes since the last flush, which the app was told were saved: about a second of them, or more if flushing falls behind.

Saying ok after the flush or before it Time runs top to bottom, the same in both columns. Three writes arrive at the same moments. With ok after the flush, the writes wait for one shared flush (group commit) and then all get ok. With ok before the flush, each write gets ok at once and the flush comes later; a power loss between the first ok and the flush erases confirmed writes: the loss window. Ok after the flush group commit Ok before the flush flush every second or so write write write one flush ok × 3 nothing confirmed is lost write ok write ok write ok flush LOSS WINDOW a power loss here erases confirmed writes

Say “ok” for a write only after a durable flush, with group commit to keep it cheap. Answer before the flush only when losing the last moments of writes is acceptable, and state that loss window in the requirements.

Redirects must be fast: say a redirect may spend a few milliseconds in the store. A store on disk often meets this already: the page cache and the store’s own cacheCacheA copy of data or results that is faster to read than the original and can be rebuilt from it. It cuts latency and load on the source while it answers most reads. Losing it makes a system slower but not wrong.Read more: Caching: When a cache pays off keep the most-read parts of its files in memory, so popular records never touch the disk.

The other option is to keep the whole dataset in memory. The estimate decides whether it fits: 100 million links of a few hundred bytes each take tens of gigabytes, which one server’s RAM can hold. The store is back to a hash map in memory, and the disk is needed only for recovery.

A disk store with a cache versus an in-memory store In a disk store, memory holds copies of hot pages (the page cache and the store's own cache), and the disk holds all data. In an in-memory store, memory holds all data in the store's own structures, and the disk holds only the log and a snapshot for recovery. Disk store MEMORY DISK Copies of hot pages page cache + store cache All data the source of truth In-memory store MEMORY DISK All data in the store's own structures Log + snapshot for recovery only HOT PAGES COPIED LOG, SNAPSHOT

A log records every change, as before, and a snapshot saves a full copy of the data now and then, so a restart loads the last snapshot and replays only the log written after it. Such stores are fast mostly because reads use memory structures directly, without decoding pages from disk; the log and snapshots still encode data, but only for recovery. Skipping disk reads matters less, since a disk store with enough memory serves hot data from memory too.

Recovery from a snapshot and the log written after it Time runs top to bottom. Every write goes to the log. After write 4, a snapshot saves all data, so writes 1 to 4 are already in it. Writes 5 to 7 are only in the log. Then the store crashes. On restart, it first loads the snapshot, then replays writes 5 to 7 from the log, and memory is back to the last write the log kept. Before the crash every write goes to the log write 1 write 2 write 3 write 4 write 5 write 6 write 7 snapshot of all data already in the snapshot ONLY IN THE LOG CRASH Restart rebuilds memory from the disk 1. load the snapshot 2. replay writes 5–7 memory is back to the last logged write

RAM costs much more than disk, and the data needs more of it than its raw size, for the store’s own structures and for growth. A restart takes as long as loading all the data back. Durability is a setting, the same choice of when to say “ok”:

Persistence Lost in a crash or power loss Use as
None Everything A cache only
Snapshots only Changes since the last snapshot A main store, if that loss is acceptable
Log flushed once a second About a second of writes, more if flushing falls behind A main store, if that loss is acceptable
Log flushed on every write Nothing that was confirmed A main store

A cache holds only copies of data kept elsewhere. RedisRedisAn in-memory server that stores data structures under keys and runs commands on them. A small command takes far less time than the network round trip, so it often sits next to a database for caches, sessions, counters, and queues. Its copies are asynchronous, so a crash or failover can lose recent writes.Read more: Redis: Taking hot data off the database offers all of these settings, so it works as a main store or as a cache (more on saving Redis data to disk). Memcached writes nothing to disk and is always a cache. How to put a cache in front of the main store is a topic of its own.

Start with a disk store and let its cache hold the hot data. Keep all data in memory when the latency target needs it, and the data, its growth, and the restart time fit the RAM budget.

The shortener grows to billions of links, and every redirect now records a click: the code, the time, and details such as the country. Clicks arrive by the thousands per second, and a stats page shows the clicks of one link over the last week:

SELECT time, country
FROM clicks
WHERE code = ?
AND time >= ?;

The hash index fails here twice: billions of keys do not fit in RAM, and a hash map cannot return a range.

Both problems go away if the records in each segment are sorted by key. Here the key is (code, time), so a segment holds all clicks of one code together, ordered by time. A segment sorted by key is an SSTable (sorted string table).

Order allows a sparse index. The store splits each SSTable into blocks of a few kilobytes and keeps in memory only the first key of each block. To find a key, it takes the block whose first key is the closest one below it, reads that block, and scans it. The index holds one key per block instead of one per record, so it is much smaller and fits in RAM for far more data.

A sparse index over an SSTable On disk, an SSTable holds records sorted by (code, time), split into blocks of three. In memory, the sparse index keeps only the first key of each block. To read abc,fri, the store picks block 1, whose first key abc,thu is the closest one below, and reads only that block. The records of abc lie next to each other, so a range of them is read in one pass. MEMORY Sparse index abc,mon → block 0 abc,thu → block 1 kq7,tue → block 2 DISK SSTable abc,mon abc,tue abc,wed BLOCK 0 abc,thu abc,fri kq7,mon BLOCK 1 kq7,tue kq7,wed zzz,mon BLOCK 2 READ abc,fri range: the clicks of abc lie together and are read in one pass

Order also gives ranges. Inside each SSTable, records with nearby keys lie next to each other, so a range of keys, such as the clicks of one link over a week, is read in one pass.

Compaction gets cheaper too. Merging sorted segments is one sequential pass over each of them, like the merge step of merge sortMerge sortA sorting method that sorts parts separately and then merges the sorted parts in one pass. Merging sorted inputs reads each of them once, in order, which suits files on disk.Read more: Wikipedia: Merge sort, and the result is sorted as well.

Merging two SSTables in one sequential pass Records are lined up by key. SSTable 1 (older) holds abc v1, kq7 v1, zzz v1. SSTable 2 (newer) holds abc v2 and mmm v1. Both are read in key order at the same time and written out as one sorted file: abc v2, kq7 v1, mmm v1, zzz v1. For the same key, the newer record wins and abc v1 is dropped. READ IN KEY ORDER SSTable 1 older abc,v1 kq7,v1 zzz,v1 SSTable 2 newer abc,v2 mmm,v1 MERGE New SSTable one sequential write abc,v2 kq7,v1 mmm,v1 zzz,v1 same key: the newer record wins, abc,v1 is dropped

Writes arrive in any key order, though, and appending to a file cannot keep it sorted.

The sorting has to happen before the disk. Each write first goes to a sorted structure in memory, the memtable, such as a balanced treeBalanced treeA sorted tree that keeps all paths from the root to the leaves about the same length. Lookups, inserts, and range reads stay fast as the tree grows. Red-black and AVL trees are common in memory.Read more: Wikipedia: Self-balancing binary search tree. Inserting into it in any order is cheap, and it always stays sorted. When the memtable reaches a few megabytes, the store writes it out in key order as a new SSTable, in one sequential write, and starts a new memtable.

The memtable lives in memory, so a crash would lose it. To prevent that, each write is first appended to a log on disk and only then added to the memtable. This is the write-ahead log (WAL). It serves only to rebuild the memtable after a crash, and the store deletes it once the memtable is on disk as an SSTable. Saying “ok” follows the same rule as before: after the log is flushed.

The write path of an LSM-tree Writes arrive in any key order: kq7, abc, zzz, abc. Step 1: each write is appended to the WAL on disk in arrival order, and the log is flushed. Step 2: it is inserted into the memtable in memory, a sorted tree, and the store says ok. Step 3: when the memtable is full, it is flushed in key order as a new SSTable in one sequential write, and its WAL is deleted. WRITES kq7 abc zzz abc 1. APPEND, FLUSH 2. INSERT → OK MEMORY Memtable kq7 abc zzz key order DISK WAL kq7 abc zzz abc arrival order, only appended New SSTable abc kq7 zzz key order, one sequential write then its WAL is deleted 3. FLUSH

This design, a memtable in front of sorted files that are merged in the background, is the LSM-tree (log-structured merge-tree). The disk sees only large sequential writes: the log, the flushes, and compaction. So writes tend to be fast.

The parts of an LSM-tree In memory: the memtable takes writes, and a full memtable is being flushed. On disk: the WAL, and SSTables from newest to oldest. Flushes cross from memory to disk as new SSTables, and compaction merges older SSTables in the background. WRITES MEMORY Memtable takes writes Full memtable being flushed WHEN FULL DISK WAL rebuilds the memtable SSTable SSTable SSTable SSTable NEWEST OLDEST FLUSH compaction merges older files in the background

Every flush adds a file, and a read may have to look through many of them.

A key can be in the memtable or in any SSTable, and a newer version hides the older ones. So a read checks the memtable first, then the SSTables from newest to oldest, and stops at the first match. That is the basic path; real engines narrow it, for example by skipping files whose key range cannot hold the key. Still, a key that is old or missing makes the read look into many files.

An LSM-tree read checks the newest data first A read for mmm checks the memtable first, then the SSTables from newest to oldest. SSTable 1 holds keys abc to kq7, so it is skipped. SSTable 2 does not hold mmm. SSTable 3 holds it, so the read stops there, and SSTable 4 is not read. MEMORY DISK Memtable all keys not here SSTable 1 abc – kq7 skipped: range SSTable 2 abc – zzz not here SSTable 3 kaa – zzz found, stop SSTable 4 abc – zzz not read NEWEST OLDEST READ mmm newest first, stops at the first match

A Bloom filter next to each SSTable cuts this down. It is a small bit array built from the file’s keys, and it answers “definitely not here” or “maybe here”. A read asks the filter first and opens only the files that may hold the key. A “maybe” for a key that is not there is a false positive, and the read opens that file for nothing; a “no” is never wrong. Filters help lookups by key, not ranges: a range read still merges the matching parts of several files.

A Bloom filter says definitely not here or maybe here The key mmm is hashed to bit positions 2, 5, and 9. In the filter of file A, bit 5 is 0, so mmm is definitely not in file A and the file is skipped. In the filter of file B, all three bits are 1, so mmm may be there and file B is opened. Below, the four outcomes: true positive, the file is opened and the key is found; false positive, the file is opened for nothing; false negative, never happens; true negative, the file is skipped. Key mmm hashes to bits 2, 5, 9 0 1 2 3 4 5 6 7 8 9 10 11 File A 0 1 1 0 0 0 0 1 0 1 0 0 definitely not here bit 5 is 0: file A is skipped File B 1 0 1 0 0 1 1 0 0 1 1 0 maybe here bits 2, 5, 9 are 1: file B is opened KEY IS IN THE FILE KEY IS NOT IN THE FILE Filter: maybe positive True positive file opened, key found False positive file opened for nothing Filter: no negative False negative never happens True negative file skipped A "maybe" can be wrong; a "no" never is.

Compaction keeps the number of files down. It merges SSTables in the background, and two strategies trade read cost against write cost. Size-tiered compaction merges files of similar size: it rewrites data fewer times, but reads check more files, and merges need more spare disk space. Leveled compaction keeps files in levels, and apart from the newly flushed files, the files of one level do not overlap in keys: a lookup by key checks the newly flushed files and at most one file in each other level, and data takes less space, but the same data is rewritten more often.

Size-tiered and leveled compaction Size-tiered compaction merges files of similar size into bigger files: four small, two medium, one large, and every file can hold any key from a to z. A lookup for p may check all seven files. Leveled compaction keeps levels: level 0 holds newly flushed files that may overlap; in levels 1 and 2 the files split the keys without overlap. A lookup for p checks at most the two level 0 files, n to s in level 1, and p to r in level 2: up to four files. Size-tiered: more files per lookup, data rewritten fewer times, merges need spare space. Leveled: fewer files per lookup, data rewritten more often, less space. Size-tiered merges files of similar size Leveled levels split the keys a–z a–z a–z a–z a–z a–z a–z L0 L1 L2 a–z may overlap a–f g–m n–s t–z a–c d–f g–k l–o p–r s–z LOOKUP p: UP TO 7 FILES LOOKUP p: UP TO 4 FILES READS more files per lookup fewer files per lookup REWRITES data rewritten fewer times data rewritten more often DISK SPACE merges need spare space less space

Compaction is also the weak spot. It rewrites the same data several times and competes with requests for the disk. When writes outrun it, files pile up, reads slow down, and in the end the engine slows down or stalls writes, so latency jumps.

Use an LSM-tree for a sustained heavy load of writes, such as clicks, events, or metrics. Look elsewhere when read cost or compaction stalls hurt more than write speed.

Links are read on every redirect, where a steady lookup time matters more than write speed.

Links need one sorted structure on disk, so that a read goes straight to its key instead of checking several files. The B-tree keeps such a structure and changes it in place.

A B-tree splits the data into fixed-size pages of a few kilobytes and links them into a tree. A leaf page holds a sorted run of keys with their values. A page above the leaves holds keys that divide the range and the numbers of the pages below: keys from a to f are on page 12, from g to m on page 40. A lookup starts at the root and follows one page per level down to a leaf. Each page points to hundreds of pages below it, so billions of keys need only three or four levels. The upper levels are read all the time and stay in memory, so a lookup usually costs one or two disk reads, often none.

A B-tree of pages The root page holds key ranges and page numbers: a to f on page 12, g to m on page 40, n to z on page 77. Every page is stored on disk; the upper levels stay cached in memory. The leaf pages on disk hold sorted keys. A lookup for kq7 follows one page per level: root, then page 40. Leaves point to their neighbors, so a range read walks along them. MEMORY upper pages stay cached in memory a–f → 12 g–m → 40 n–z → 77 Root DISK PAGE 12 ab7 abc f01 PAGE 40 gx1 h20 kq7 PAGE 77 pq2 xyz zzz FIND kq7 leaves are in key order and point to their neighbors: a range read walks along them

Leaves are kept in key order and point to their neighbors, so a range read finds its first key and walks along the leaves.

A write finds its leaf and changes it in place. When a leaf is full, it splits in two, and its parent gets an entry for the new page. A split can climb up to the root, and the tree stays balanced.

A page split Before: the parent maps a to m to page 12 and n to z to page 40, and page 12 is full with b, d, f, h. Inserting e splits page 12 into two partly filled pages: page 12 with b, d, e and page 13 with f, h. The parent gets a new entry, f to m on page 13. Before a–m → 12 n–z → 40 b d f h PAGE 12: FULL INSERT e After a–e → 12 f–m → 13 n–z → 40 b d e PAGE 12 f h PAGE 13: NEW two partly filled pages; the parent gets a new entry

Changing pages in place is risky. A crash can leave a page half-written, or a split half-done, with a parent pointing to a page that was never written. So a B-tree also keeps a WAL: every change goes to the log, and the log reaches disk before the page it changes. After a crash, the store repairs the pages from the log. Saying “ok” works as before: after the log is flushed, while the changed pages are written later.

A B-tree writes the log before the page Step 1: the change is appended to the WAL on disk and flushed. Step 2: the page is changed in memory, and the store says ok. Step 3: later, the changed page is written to disk. If a crash comes before step 3, the store repairs the page from the WAL. MEMORY DISK change Page 40 changed in memory 1. LOG, FLUSH WAL 2. CHANGE → OK Page 40 on disk old until step 3 3. WRITE LATER REPAIR a crash before step 3: repaired from the WAL

A B-tree writes to random places on disk, and a change of a few bytes rewrites a whole page. Deletes and splits leave pages partly empty, so the files take more space than the data.

A B-tree is an alternative to the LSM-tree, not an upgrade: they are the two main storage engines, and each one wins on different workloads.

Use a B-tree for data that is read by key and by range, with a mix of reads and writes. Consider an LSM-tree when measured write load is more than a B-tree keeps up with.

Links are read on every redirect and rarely change. Clicks are written all the time and read in ranges for stats. The engines are compared by amplification: how much more the disk reads, writes, or stores than the data the app asked for. The table shows tendencies; the real numbers depend on the workload and the engine’s settings.

B-tree LSM-tree
Read amplification One path down the tree, steady May check several files; Bloom filters help reads by key
Range reads Walk along the leaves Merge the matching parts of several files
Write amplification Log plus a whole page, even for a small change Log, flush, and every compaction; often lower overall
Space amplification Partly empty pages Compresses well, but keeps old versions until compaction
Latency Steadier Spikes when compaction falls behind

For the shortener, links go to a B-tree. Most relational databases, such as PostgreSQL and MySQL, keep their indexes in B-trees, and MySQL also keeps the rows themselves in the tree of the primary keyPrimary keyThe column or columns that identify each record of a table. No two records share it. The database keeps an index on it to find records by it.Read more: Wikipedia: Primary key. Clicks can live in the same store while their rate is modest. When clicks arrive faster than the B-tree can keep up, they move to an LSM-tree, the engine of Cassandra and RocksDB.

When asked to pick a storage engine, start from the access pattern. Reads by key and by range with a mix of writes: a B-tree, the safe default. A sustained heavy load of writes: an LSM-tree, and name its price, slower reads and compaction. Present both as tendencies that depend on the workload, and offer to confirm the choice with a load test of your own reads and writes.

The “My links” page shows a user’s latest links:

SELECT code, url
FROM links
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT 20;

The index on code cannot find records by user_id, so without another index this query reads every record. A secondary index fixes this: one more sorted structure, usually a B-tree, ordered by another column. Many records share a value of that column, so each entry holds the value together with the record’s key and points back to the record. Depending on the database, it points to the record’s place in the table file, or to its primary key, which costs a second lookup.

A secondary index finds records by another column In the table, the records of user u42 (abc and mmm) lie apart. The index on user_id is sorted by user_id, so the u42 entries lie together; each entry holds the user_id and the record's code and points back to the record. Below, the two kinds of pointer: the record's place in the table file, reached in one jump; or its primary key, which needs a second lookup in the index on code. WHERE user_id = u42 Index on user_id sorted by user_id: u42 entries lie together Table u42 records lie apart u17 | kq7 abc | u42 | https://a… u42 | abc kq7 | u17 | https://k… u42 | mmm mmm | u42 | https://m… u77 | zzz zzz | u77 | https://z… AN ENTRY POINTS TO ITS RECORD BY its place in the table file: one jump u42 | page 7 abc | u42 | https://a… its primary key: a second lookup in the index on code u42 | abc abc → page 7 abc | u42 | https://a…

An index on user_id alone finds the matching records, but they still have to be sorted by created_at. A composite index on (user_id, created_at) orders entries by user_id and, within each user_id, by created_at, like a phone book sorted by last name and then by first name. The query finds the newest entry of its user_id and walks back 20 entries, without sorting the matching records.

The column order matters. Put the columns compared with = first and the column used for sorting or a range last. This index usually cannot help a query by created_at alone, just as a phone book does not help find everyone with one first name.

The query still reads each of the 20 records to get url. If the index also stores code and url, it is a covering index: the store answers the query from the index without reading the records. The price is a bigger index.

Every index takes space, and every write that changes its columns must update it too. On a table with many writes and few reads, each extra index slows down the main work. A full scan is still the right choice for small tables, or when a query needs most of the rows anyway.

A B-tree keeps values along one sorted line, so it serves equality, ranges, and sorting. Searching an area on a map or a word inside text does not fit one line and needs other index types.

Add the smallest index that serves a frequent query which picks a small share of the rows: columns compared with = first, then the sort or range column. Make it a covering index only when reading the records is the proven bottleneck. Check in the query planQuery planThe steps a database chooses to run a query, such as which index to use. Databases show it on request (EXPLAIN in SQL). Check it to see whether a query really uses the index you added.Read more: PostgreSQL: Finding why a query is slow that the database uses the index.

Analysts want totals over all clicks, such as clicks per country per day:

SELECT country, date(time) AS day, count(*)
FROM clicks
GROUP BY country, day;

All the engines so far keep each record whole in one place: row storage. It suits the app, which reads and writes a few records at a time by key. This query reads billions of records but needs only two columns out of many, so with row storage the disk reads whole rows and the store throws most of each one away.

Column storage keeps the values of each column together, for example one file per column, in the same row order. The query reads only the country and time columns. Values in a column have one type and often repeat, such as the same country thousands of times, so a column compresses much better than rows, and the disk reads even less.

Row storage and column storage The same five clicks with four columns: code, time, country, device. Row storage keeps each row together, so a query that needs only time and country still reads all 20 values and drops code and device. Column storage keeps each column together, so the query reads only the time and country columns: 10 values. A column holds values of one type that often repeat, so it compresses well. Row storage each row stored together CODE TIME COUNTRY DEVICE abc mon US ios kq7 mon US web abc tue US ios zzz tue DE web abc wed DE ios Column storage each column stored together CODE TIME COUNTRY DEVICE abc mon US ios kq7 mon US web abc tue US ios zzz tue DE web abc wed DE ios READS ALL 20 VALUES code and device are read, then dropped READS 10 VALUES one type per column: compresses well

The price is on writes. One new row touches every column, so a column store collects new rows in memory and writes them to the columns in batches, much like the memtable. It is also poor at reading or changing single records.

The two workloads have names. OLTP (online transaction processing) is the app’s work: many small reads and writes by key. OLAP (online analytical processing) is the analysts’ work: large scans and totals. On one store they compete for the disk, so analytics usually gets its own column store, loaded from the main store in batches or as a stream. Reports may lag behind the app, and the loading method decides by how much.

Keep the app’s records in row storage. Move analytics to a column store when queries scan large parts of the data and reports can tolerate the loading delay.

  • Keep data that must survive a restart on disk; a map inside the app fits only a throwaway prototype. Appending to a log is the cheapest write, but reads have to scan it.
  • An index speeds up reads but costs space and work on every write, so index only what queries need. A hash index fits lookups by exact key while all keys fit in RAM.
  • Split the log into segments and compact them in the background. A delete is a tombstone, and deleted data stays on disk until compaction removes it.
  • A write is durable only after a flush to disk. Say “ok” after the flush, with group commit to keep it cheap; answer earlier only with an agreed loss window.
  • Start with a disk store whose cache holds the hot data. Keep all data in RAM when the latency target needs it and the data, its growth, and the restart time fit the budget. Without persistence, an in-memory store is only a cache.
  • Sorted files (SSTables) allow a sparse index, range reads, and cheap merges.
  • An LSM-tree collects writes in a sorted memtable, protects it with a WAL, and flushes it as sorted files.
  • LSM reads may check several files: Bloom filters skip files for lookups by key, and compaction keeps the file count down but can fall behind and stall writes.
  • A B-tree follows a short path of pages to a key, and cached pages save most disk reads; leaves in key order serve ranges. It rewrites whole pages in random places and protects them with a WAL.
  • Pick a B-tree by default for reads by key and by range with a mix of writes. Consider an LSM-tree when measured write load is more than a B-tree keeps up with, and pay for it with read work and compaction. Both are tendencies that depend on the workload.
  • Add the smallest index that serves a frequent query which picks a small share of the rows: columns compared with = first, then the sort or range column. Make it a covering index only for a proven bottleneck. A full scan is fine for small tables or when most rows match.
  • Use row storage for the app’s records. Use column storage for large scans and totals when reports can tolerate the loading delay.

© 2026 Grig Alex · CC BY-SA 4.0