Storage Engines and Indexing

📖 14 min read

The Storage Engine Decides What a Database Is Good At

A database has two halves. The query layer parses requests and decides how to answer them. The storage engine underneath decides how bytes are laid out on disk and how they’re found again. Most of what makes one database fast at writes and another fast at range scans (reading every key between two values, such as all orders from last week) comes from the storage engine. A few servers let you choose one per table. Percona Server and MariaDB, for example, offer MyRocks, an engine built on RocksDB, alongside the default InnoDB.

Two physical facts drive every design. Disks and SSDs read and write in blocks, so touching one byte costs a whole block. And sequential I/O is much cheaper than random I/O, dramatically so on spinning disks and still noticeably on SSDs. Each engine design below is a different answer to how to turn random application writes into I/O the hardware handles well.


Pages and the Buffer Pool

Most engines store data in fixed-size pages: 8 KB in PostgreSQL and SQL Server, 16 KB by default in InnoDB. A page holds many rows, and it’s the unit the engine reads from and writes to disk. Reading one row means reading its whole page.

Pages that have been read stay cached in memory in the buffer pool (PostgreSQL calls it shared buffers). A query whose pages are already cached never touches the disk, which is why a database’s working set, the data its queries touch regularly, fitting in memory matters so much to its performance. A change is first made to the cached page, which is then “dirty” until the engine writes it back.


The Write-Ahead Log

Writing every dirty page to disk before confirming a commit would be slow, because the pages a transaction touches are scattered across the file. Engines instead use a write-ahead log (WAL). Before a change is applied to a page, a record describing it is appended to the log, and a commit is confirmed once its log records are safely on disk.

The appends are sequential, which makes them cheap, and the dirty pages are written back later in the background during checkpoints. PostgreSQL calls it the WAL, InnoDB calls it the redo log, and SQL Server calls it the transaction log, but the mechanism is the same.

If the server crashes, recovery replays the log from the last checkpoint and rebuilds any change that hadn’t reached its page yet. Engines that update rows in place, such as InnoDB and SQL Server, also use the log or a separate undo log to roll back transactions that were in progress at the crash. PostgreSQL never overwrites a row, so an unfinished transaction’s new row versions simply stay invisible.

A log of every change is useful beyond recovery. PostgreSQL and SQL Server replicas stay in sync by replaying the primary’s log, and change data capture tools read it to stream committed changes to other systems. MySQL keeps a separate log for this, the binary log, which records changes at a higher level than InnoDB’s redo log and is what its replicas and CDC tools read.


B-Trees: Update in Place

The B-tree is the default index structure in PostgreSQL, MySQL’s InnoDB, SQL Server, Oracle, and MongoDB’s WiredTiger, and in InnoDB, SQL Server clustered tables, and WiredTiger the table itself is stored as one. It’s a balanced tree of pages. The root and internal pages hold keys and pointers to child pages, and the leaf pages hold the keys with either the rows themselves or pointers to them. In PostgreSQL, InnoDB, and SQL Server, leaves are linked to their neighbors, so a range scan finds its starting key and then walks sideways.

                    [ root: 100 | 500 ]
                   /         |          \
       [ 20 | 60 ]     [ 200 | 350 ]     [ 700 | 900 ]      internal pages
       /   |    \       /    |    \        /    |    \
    leaf ↔ leaf ↔ leaf ↔ leaf ↔ leaf ↔ leaf ↔ leaf ↔ leaf     leaves linked for range scans

Because each page holds hundreds of keys, the tree stays shallow. With around 500 keys per page, three levels reach about 125 million entries and four reach tens of billions, so a lookup reads a handful of pages, and the upper levels are almost always cached.

Writes find the right leaf and modify it in place. When a leaf fills up, it splits into two and the parent gains a pointer, which can cascade upward. That in-place update is the B-tree’s defining trade-off. Reads are fast and predictable, but each small write dirties a whole page, and inserts that land at random positions in the key space scatter writes across the file.


LSM Trees: Append Now, Merge Later

The log-structured merge tree (LSM tree) never updates data in place. Cassandra, ScyllaDB, HBase, and RocksDB use it, and RocksDB in turn sits under many other systems, including TiDB’s storage layer, while CockroachDB uses its own LSM engine, Pebble.

A write is appended to the WAL and inserted into a sorted in-memory structure called the memtable. When the memtable fills up, it’s written to disk in one sequential pass as an immutable sorted file, an SSTable (sorted string table). No file is ever modified after it’s written. An update is a newer entry for the same key, and a delete is a marker called a tombstone.

Flow

LSM Tree With Leveled Compaction

Writes buffered in memory, flushed to immutable files, merged level by level.

How an LSM tree handles writes A write is appended to the write-ahead log on disk and inserted into the memtable, a sorted structure in memory. When the memtable is full, it is flushed to disk as an immutable SSTable in level 0. Compaction merges SSTables from level 0 into level 1 and from level 1 into level 2, keeping the newest version of each key and dropping overwritten and deleted values. MEMORY DISK Write insert Memtable [sorted, in memory] append Write-ahead log [sequential append] flush when full Level 0 SSTable SSTable SSTable Level 1 SSTable SSTable SSTable SSTable Level 2 SSTable SSTable SSTable SSTable compaction merge, keep newest compaction drop old versions

Left alone, SSTables would pile up and reads would slow down. A background process called compaction merges SSTables together, keeping the newest version of each key and discarding overwritten values and deleted keys. Engines differ in how they schedule it. Leveled compaction, the default in RocksDB and the arrangement the figure shows, keeps the files within each level below level 0 non-overlapping and merges a file down into the next, larger level, which keeps reads fast at the cost of more rewriting. Size-tiered compaction, Cassandra’s long-standing default, waits until several files of similar size exist and merges them, which rewrites less but leaves more files for a read to check and more space held by old versions.

Reads pay for the write speed. A point lookup checks the memtable, then SSTables from newest to oldest until it finds the key. To avoid opening every file, SSTables are commonly configured with a Bloom filter, a compact structure that can answer “this key is definitely not in this file” without reading it. It occasionally answers “maybe” for a key that isn’t there, which costs one wasted file read but never a wrong answer. Standard Bloom filters help point lookups, not general range scans. A range scan has to read the matching key range from the memtable and from every file that could overlap it, merging them as it goes.

Comparing B-Trees and LSM Trees

  B-tree LSM tree
Write path Find the page, modify it in place Append to memtable, flush sequentially
Write throughput Good, limited by random page writes Tends to be higher, since disk writes are sequential
Point reads One path from root to leaf May check several files, helped by Bloom filters
Latency Predictable Compaction runs in the background and can cause latency spikes
Space Pages carry free space after splits and fragment over time Old versions occupy space until compacted, but sorted immutable files compress well
Typical homes Relational databases and most transactional systems Write-heavy and distributed engines

Both designs pay three kinds of overhead, and tuning moves cost between them. Write amplification is how many bytes reach the disk per byte the application wrote: a B-tree writes a log record and then a whole page for a small change, and an LSM tree writes a log record, flushes the memtable, and then rewrites the same data at each compaction. Read amplification is how many pages or files a read touches. Space amplification is how much disk the data takes beyond its logical size. Leveled compaction spends writes to save reads and space, and size-tiered compaction does the reverse. None of the three can be minimized without raising another, which is why the choice is about workload rather than one design being faster overall.


Row and Column Layout

Everything above assumes row-oriented storage, where all of a row’s columns sit together on the same page. That suits operational workloads, which read or update whole records at a time.

Structure

Row vs Column Layout

The same four orders stored row by row and column by column.

Four orders laid out in row-oriented pages and in column-oriented storage In row-oriented storage, each page holds whole rows: page 1 holds orders 1 and 2 with their id, name, total, and date together, and page 2 holds orders 3 and 4. Reading one order reads one page. In column-oriented storage, each column is stored separately: all ids together, all names together, all totals together, and all dates together. Summing the total column reads only the totals. ROW-ORIENTED COLUMN-ORIENTED Page 1 1 | Alice | 99.99 | 2026-01-15 2 | Bob | 45.50 | 2026-01-15 Page 2 3 | Carol | 200.00 | 2026-01-16 4 | Dan | 12.00 | 2026-01-16 Reading one order reads one page id 1 2 3 4 name Alice Bob Carol Dan total 99.99 45.50 200.00 12.00 date 01-15 01-15 01-16 01-16 SUM(total) reads only the total column

Column-oriented storage keeps each column’s values together instead. A query that sums order_total across a billion orders reads only that column rather than every column of every row. Values in one column tend to resemble each other, so they compress far better, and engines can process them in batches. Data warehouses such as Amazon Redshift, Google BigQuery, and Snowflake store data this way, as do analytical engines like ClickHouse and file formats like Apache Parquet.

The cost is the mirror image. Inserting or updating one row touches every column’s storage separately, so columnar systems favor bulk loads over row-at-a-time writes. That split is why operational and analytical workloads usually live in different systems.


Indexes

What an Index Costs

Without an index, finding the orders for customer 12345 means scanning every row in the orders table. A secondary index is a separate structure, usually a B-tree, that maps a column’s values to the rows that hold them, so the lookup becomes a short tree walk.

Every index has to be maintained as rows are inserted, updated, and deleted. So each index speeds up the reads that use it and slows down all writes to the table, and it takes disk space and buffer pool memory. An index nobody queries is pure cost.

Clustered Tables and Heap Tables

Engines differ in where the rows themselves live, and it changes how keys should be designed.

In InnoDB, and in SQL Server tables with a clustered index, the table is a B-tree ordered by the primary key, with rows stored in its leaves. Secondary indexes store the primary key, so a lookup through a secondary index walks two trees. Primary keys that arrive in increasing order append to the right-hand edge of the tree, while random keys such as UUIDv4 values insert all over it, causing page splits and scattered writes. Time-ordered identifiers such as UUIDv7 avoid that.

PostgreSQL stores rows in an unordered heap, and every index, including the primary key’s, points to a row’s physical location. Insert order matters less for the table itself, though a random key still scatters writes across its own index. PostgreSQL writes each updated row as a new version and leaves the old one behind for cleanup. When the update changes no indexed column and the page has room, the new version goes on the same page and the indexes are left alone, which PostgreSQL calls a HOT (heap-only tuple) update. Otherwise every index gets a new entry.

Structure

Clustered Table vs Heap Table

A secondary-index lookup in a clustered table versus a heap table.

Looking up a row through a secondary index in a clustered table and in a heap table In a clustered table, as in InnoDB or SQL Server, a secondary index on email returns the row's primary key, 42. The engine then walks the primary-key B-tree, which is the table itself, down to the leaf page holding the full row. That is two tree walks. In a heap table, as in PostgreSQL, the index on email returns the row's physical location, page 17 slot 3, and the engine reads that heap page directly. That is one index walk and one page read. CLUSTERED TABLE: INNODB, SQL SERVER HEAP TABLE: POSTGRESQL Secondary index [on email] key 42 Primary-key B-tree [the table itself] Leaf: full row Two tree walks per secondary lookup Index [on email] page 17, slot 3 Heap page 17 [rows in no order] One index walk, then one page read

Increasing keys have the opposite problem in distributed databases that split tables into key ranges across nodes, such as CockroachDB, TiDB, and Spanner. Every new row lands in the last range, so one node takes all the inserts. Those systems recommend random or hashed keys, or a hashed prefix on a time-ordered key, to spread the writes.

Composite Indexes and Column Order

An index on several columns is sorted by the first column, then by the second within each value of the first, and so on. An index on (customer_id, created_at) serves WHERE customer_id = 42 and WHERE customer_id = 42 AND created_at > '2026-01-01', because both start from the leading column.

A query filtering only on created_at traditionally can’t use that index efficiently, since matching rows are scattered across every customer’s section of the tree. Some engines can now skip-scan in that case, jumping between the distinct values of the leading column: Oracle, MySQL since 8.0.13, and PostgreSQL since version 18. Skip scan works well when the leading column has few distinct values and poorly when it has many, so column order still matters. Put the columns queries filter on by equality first.

Covering Indexes

When an index contains every column a query needs, the engine can answer from the index alone without visiting the table. That’s a covering index, and the resulting plan is an index-only scan. PostgreSQL and SQL Server let you add non-key columns to an index with an INCLUDE clause for exactly this purpose, so the extra columns are carried in the leaves without affecting the sort order.

Index Types Beyond the B-Tree

Index structure How it works Good for
B-tree Sorted balanced tree Equality, ranges, sorting, prefix matches; the default
Hash Hash of the key Equality only
Inverted (PostgreSQL GIN) Maps each element or term to the rows containing it Arrays, JSON containment, full-text search
Spatial (R-tree, PostgreSQL GiST) Nests bounding boxes Geometry and “what’s near this point” queries
Block range (PostgreSQL BRIN) Stores the minimum and maximum value per range of pages Very large tables whose physical order follows the column, such as append-only timestamps

Two modifiers apply on top of most of these structures. A partial index covers only rows matching a condition, such as WHERE status = 'pending', which keeps it small when queries always filter the same way. An expression index indexes a computed value, such as lower(email), for queries that filter on that expression. PostgreSQL supports both. MySQL has no partial indexes but has supported expression indexes since 8.0.13, and SQL Server calls partial indexes filtered indexes and indexes expressions through computed columns.


Why an Index Goes Unused

The query planner chooses between available indexes and a full scan by estimating cost, and it can reasonably decline an index that exists.

The query matches too many rows. Reading the index and then fetching a large fraction of the table’s pages one at a time costs more than scanning the table sequentially. An index on a boolean column is rarely used for this reason.

The query wraps the column. WHERE lower(email) = 'a@x.com' can’t use a plain index on email, because the index is sorted by email, not by lower(email). An expression index on lower(email) fixes it, as does storing the value already normalized. In MySQL and SQL Server, implicit type conversions, such as comparing a text column to a number, cause the same problem. PostgreSQL rejects that comparison with an error instead.

The statistics are stale. The planner’s estimates come from sampled statistics about each column. After a large data change, stale statistics can make it pick the wrong plan until they’re refreshed.

EXPLAIN shows the plan the planner chose, and EXPLAIN ANALYZE in PostgreSQL (or the actual execution plan in SQL Server) runs the query and shows where the time went. Checking the plan is the only reliable way to know whether an index is being used.


Found this guide helpful? Share it with your team:

Share on LinkedIn