← Back to list

How CockroachDB Stores Data: KV Engine Explained with Real Internals

Foundation: Everything Is KV

Meenakshi Kumari · 2026-04-28 12:01 · 0 claps · 4.9 min read paywalled
#cockroachdb #kv-store #index #distributed-systems #pebble
Open on Medium ↗

How CockroachDB Stores Data: KV Engine Explained with Real Internals

Foundation: Everything Is KV

CRDB stores ALL data — tables, indexes, metadata — as key-value pairs in RocksDB (now Pebble, a Go port of RocksDB). There are no “rows” at the storage layer. A SQL row is encoded into one or more KV pairs.

SQL Layer:    Tables, Rows, Indexes, Columns
                          │                                                                                                                                                 
                          ▼  encode/decode                                                                                                                                  
  KV Layer:     key → value                                                                                                                                                 
                          │                                                                                                                                                 
                          ▼  store                                                                                                                                          
  Pebble:       Sorted SST files on disk (LSM tree)

Key Encoding Format

Every key in CRDB follows this structure: /Table/<table_id>/<index_id>/<index_values…>/<column_family_id>

Each part is encoded using a type-aware binary encoding called “key encoding” that preserves sort order

Sort order is critical — because ranges are split on key boundaries, and range scans must work correctly, the key encoding must sort the same way the data sorts.

Concrete Example: The Holdings Table

CREATE TABLE holdings (
 user_id INT, 
 stock_symbol VARCHAR(10),
 qty INT, 
 avg_price DECIMAL,
 last_updated TIMESTAMP, 
 PRIMARY KEY (user_id, stock_symbol)
 );

 CRDB assigns internal IDs:
 Table ID: 54 
 Primary Index ID: 1 (always 1 for primary key)

How Primary Key Rows Are Stored

For each row, CRDB creates one KV entry per column family (by default all non-PK columns go into column family 0):

 -- Insert this row:                                                                                                                                                       
INSERT INTO holdings VALUES (1001, 'INFY', 50, 1423.50, '2026-04-23 10:00:00');                                                                                           

  Key:            
   /Table/54/1/1001/"INFY"/0                                                                                                                                                 
    │      │ │  │     │    │                                                                                                                                                 
    │      │ │  │     │    └── column family ID (0 = default)                                                                                                                
    │      │ │  │     └────── stock_symbol value ("INFY")                                                                                                                   
    │      │ │  └──────────── user_id value (1001)                                                                                                                          
    │      │ └─────────────── index ID (1 = primary index)                                                                                                                  
    │      └───────────────── table ID (54)                                                                                                                                 
    └──────────────────────── system prefix                                                                                                                                 

  Value:                                                                                                                                                                    
  {qty: 50, avg_price: 1423.50, last_updated: '2026-04-23 10:00:00'} 

  Encoded as: protobuf/columnar binary                                                                                                                                      
  Only non-PK, non-family-key columns stored here                                                                                                                           
  PK columns (user_id, stock_symbol) are already in the KEY                                                                                                                 
  so they are NOT repeated in the value

 Full KV pair:                                                                                                                                                             
  KEY:   /Table/54/1/1001/"INFY"/0                                                                                                                                          
  VALUE: {qty:50, avg_price:1423.50, last_updated:'2026-04-23T10:00:00'} 

Multiple Rows — How They Look in Sorted Order

INSERT INTO holdings VALUES
    (1001, 'INFY',  50,  1423.50, ...),                                                                                                                                     
    (1001, 'RELI',  100, 2891.00, ...),
    (1001, 'TCS',   25,  3456.00, ...),                                                                                                                                     
    (2005, 'INFY',  200, 1410.00, ...),                                                                                                                                     
    (2005, 'WIPRO', 75,  412.00,  ...);                                                                                                                                     

  KV store (sorted by key — this is how Pebble stores them):                                                                                                                

  KEY                              VALUE                                                                                                                                    
  ─────────────────────────────────────────────────────────────────
  /Table/54/1/1001/"INFY"/0   →  {qty:50,  avg_price:1423.50, ...}                                                                                                          
  /Table/54/1/1001/"RELI"/0   →  {qty:100, avg_price:2891.00, ...}                                                                                                          
  /Table/54/1/1001/"TCS"/0    →  {qty:25,  avg_price:3456.00, ...}                                                                                                          
  /Table/54/1/2005/"INFY"/0   →  {qty:200, avg_price:1410.00, ...}                                                                                                          
  /Table/54/1/2005/"WIPRO"/0  →  {qty:75,  avg_price:412.00,  ...}                                                                                                          

  Notice:         
    - All of user 1001's rows are contiguous (sorted together)                                                                                                              
    - Within user 1001, stocks are sorted alphabetically                                                                                                                    
    - user 2005's rows come after ALL of user 1001's rows
    - This is why range scans by user_id are fast — sequential reads                                                                                                        

This sort order is the entire reason CRDB uses key encoding that preserves 
order. A range scan WHERE user_id = 1001 just needs to read a contiguous block
 of KV pairs.

How a Primary Key Lookup Works

 SELECT qty FROM holdings WHERE user_id = 1001 AND stock_symbol = 'INFY';

  Step 1: Encode the lookup key
    /Table/54/1/1001/"INFY"/0                                                                                                                                               

  Step 2: Point lookup in Pebble (like a dictionary lookup)                                                                                                                 
    Pebble uses bloom filters → O(1) check if key exists
    Then binary search in SST files                                                                                                                                         

  Step 3: Found the KV pair                                                                                                                                                 
    KEY:   /Table/54/1/1001/"INFY"/0
    VALUE: {qty:50, avg_price:1423.50, ...}                                                                                                                                 

  Step 4: Decode value → return qty=50                                                                                                                                      

  Total: single KV read — very fast

Column Families — Splitting One Row Into Multiple KV Pairs

By default all columns go in one KV pair (family 0). But you can split them:

  CREATE TABLE holdings (
      user_id      INT,                                                                                                                                                     
      stock_symbol VARCHAR(10),                                                                                                                                             
      qty          INT,                                                                                                                                                     
      avg_price    DECIMAL,                                                                                                                                                 
      last_updated TIMESTAMP,                                                                                                                                               
      PRIMARY KEY (user_id, stock_symbol),
      FAMILY core    (qty, avg_price),       -- family 0                                                                                                                    
      FAMILY metadata (last_updated)          -- family 1                                                                                                                   
  );                                   

 Now each row becomes TWO KV pairs:                                                                                                                                        

  /Table/54/1/1001/"INFY"/0  →  {qty:50, avg_price:1423.50}                                                                                                                 
  /Table/54/1/1001/"INFY"/1  →  {last_updated:'2026-04-23T10:00:00'}                                                                                                        

Why useful? 
If you frequently query qty and avg_price but rarely last_updated,
family 1 is never read → less I/O. Pebble can skip it entirely.                   

Secondary Indexes

CREATE INDEX idx_symbol ON holdings (stock_symbol);

  CRDB assigns this index ID 2.

  For each row, CRDB creates an additional KV pair for the secondary index:                                                                                                 

  Row: (user_id=1001, stock_symbol='INFY', qty=50, ...)                                                                                                                     

  Primary KV (index 1):                                                                                                                                                     
    KEY:   /Table/54/1/1001/"INFY"/0                                                                                                                                        
    VALUE: {qty:50, avg_price:1423.50, last_updated:...}                                                                                                                    

  Secondary index KV (index 2):                                                                                                                                             
    KEY:   /Table/54/2/"INFY"/1001/0                                                                                                                                        
                    │    │      │                                                                                                                                           
                    │    │      └── primary key appended (to make key unique)                                                                                               
                    │    └───────── index column value (stock_symbol)                                                                                                       
                    └────────────── index ID (2)                                                                                                                            
    VALUE: {} (empty — all needed info is in the key) 

All 5 rows' secondary index entries, sorted:                                                                                                                              

  KEY                              VALUE                                                                                                                                    
  ─────────────────────────────────────────────────────────────────                                                                                                         
  /Table/54/2/"INFY"/1001/0   →  {}      ← user 1001 holds INFY                                                                                                             
  /Table/54/2/"INFY"/2005/0   →  {}      ← user 2005 holds INFY                                                                                                             
  /Table/54/2/"RELI"/1001/0   →  {}                                                                                                                                         
  /Table/54/2/"TCS"/1001/0    →  {}                                                                                                                                         
  /Table/54/2/"WIPRO"/2005/0  →  {}                                                                                                                                         

  Sorted by stock_symbol first — so a query like:                                                                                                                           
    SELECT * FROM holdings WHERE stock_symbol = 'INFY'                                                                                                                      
  reads a contiguous block: /INFY/1001 and /INFY/2005

How the lookup works:

SELECT qty FROM holdings WHERE stock_symbol = 'INFY';                                                                                                                     

Step 1: Scan secondary index for "INFY"
   Read: /Table/54/2/"INFY"/1001/0  → {} → extract primary key: (1001, "INFY")                                                                                             
   Read: /Table/54/2/"INFY"/2005/0  → {} → extract primary key: (2005, "INFY")                                                                                             

Step 2: For each primary key found, go back to primary index                                                                                                              
  Read: /Table/54/1/1001/"INFY"/0  → {qty:50, ...}  ← INDEX BACKFILL (join)
  Read: /Table/54/1/2005/"INFY"/0  → {qty:200, ...} ← INDEX BACKFILL (join)                                                                                               

Step 3: Return qty values                                                                                                                                                 

  This is called an "index join" — two KV lookups per row                                                                                                                   
  Secondary index → get PK → primary index → get columns

Unique Index

CREATE UNIQUE INDEX idx_unique_symbol_user ON holdings (stock_symbol, user_id);

For a unique index, CRDB does NOT append the primary key to make the key unique
(because the index itself guarantees uniqueness):                                         

Non-unique index key: /Table/54/2/"INFY"/1001/0   ← PK appended                                                                                                           
Unique index key:     /Table/54/4/"INFY"/1001/0   ← same here because                                                                                                     
                                                    (symbol, user_id) IS unique                                                                                          
                                                    but:                                                                                                                 

Unique index key (single column): /Table/54/5/"INFY"/0                                                                                                                    
                                                       ← no PK appended                                                                                                     
                                                       ← value contains PK                                                                                                  
  VALUE: {user_id: 1001}    ← primary key stored in value                                                                                                                   

  Lookup: find row where stock_symbol = 'INFY'                                                                                                                              
    1. Read unique index → get PK (1001, "INFY")                                                                                                                            
    2. Read primary index with PK → get full row 

MVCC — Multiple Versions of the Same Key

CRDB is a multi-version store. Every KV write creates a new version with a timestamp. Old versions are kept until garbage collection.

User updates qty from 50 to 75:

Before update:  
KEY:                       TIMESTAMP    VALUE                                                                                                                    
/Table/54/1/1001/"INFY"/0 @12:00:00.100 {qty:50,avg_price:1423.50}                                                                                              

After update:                                                                                                                                                             
/Table/54/1/1001/"INFY"/0 @12:00:05.200 {qty:75,avg_price:1423.50} ← newest                                                                                    
/Table/54/1/1001/"INFY"/0 @12:00:00.100 {qty:50,avg_price:1423.50} ← old version                                                                               

A read at T=12:00:06 → sees qty=75  (latest version)                                                                                                                      
A read at T=12:00:03 → sees qty=50  (version before update)                                                                                                               
    (this is how AS OF SYSTEM TIME queries work) 

GC runs periodically (default 25h TTL) → deletes old versions

This is how CRDB provides snapshot isolation and time-travel queries without locking.

How Ranges Map to KV Keys

 Ranges are just contiguous spans of the KV keyspace:

  All possible keys sorted:

  /Table/54/1/1/...         ← start of holdings table                                                                                                                       
  /Table/54/1/1001/"INFY"/0                                                                                                                                                 
  /Table/54/1/1001/"RELI"/0                                                                                                                                                 
  /Table/54/1/1001/"TCS"/0                                                                                                                                                  
  /Table/54/1/2005/"INFY"/0
  /Table/54/1/2005/"WIPRO"/0                                                                                                                                                
  ...                                                                                                                                                                       
  /Table/54/1/500000/...    ← end of holdings table
  /Table/54/2/"INFY"/...    ← secondary index starts here                                                                                                                   
  ...                                                                                                                                                                       

  Range split (at ~512MB):                                                                                                                                                  
    Range 3a: /Table/54/1/1/...    →  /Table/54/1/250000/...
    Range 3b: /Table/54/1/250001/... →  /Table/54/1/500000/...                                                                                                              

  user_id 1001 → Range 3a (leaseholder: Node1)                                                                                                                              
  user_id 260000 → Range 3b (leaseholder: Node2)

 Writes to user 1001 → Node1                                                                                                                                               
 Writes to user 260000 → Node2                                                                                                                                             
 Parallel, no contention

Full Picture

SQL row: (user_id=1001, stock_symbol='INFY', qty=50, avg_price=1423.50)

  Stored as KV pairs:                                                                                                                                                       

    Primary Index:                                                                                                                                                          
      /Table/54/1/1001/"INFY"/0  @T1  →  {qty:50, avg_price:1423.50}

    Secondary Index (stock_symbol):                                                                                                                                         
      /Table/54/2/"INFY"/1001/0  @T1  →  {}                                                                                                                                 

    Covering Index (stock_symbol STORING qty):                                                                                                                              
      /Table/54/3/"INFY"/1001/0  @T1  →  {qty:50} 
  • All sorted in Pebble LSM tree on disk.
  • Multiple MVCC versions per key for snapshot isolation.
  • Split into ranges (~512MB) → each range is a Raft group.
  • Leaseholder of each range guarantees reads are current

The encoding guarantees that: — Primary key lookups = single KV point read — Range scans = sequential KV reads (fast, cache-friendly) — Secondary index lookups = index scan + primary index join (unless covering) — MVCC = multiple timestamped versions, GC’d after TTL


메타데이터
post_id
dee0f2d088b5
slug
crdb-key-value-storage-deep-dive-dee0f2d088b5
url
https://medium.com/@meenakshi_kumari/crdb-key-value-storage-deep-dive-dee0f2d088b5
canonical_url
https://medium.com/@meenakshi_kumari/crdb-key-value-storage-deep-dive-dee0f2d088b5
author_url
https://medium.com/@meenakshi_kumari
status
ok
fetched_at
2026-07-13 06:23:13