System Design · Storage Internals
Data Representation and Storage Concepts
Checkout writes total = 19.99. That value is not “nineteen ninety-nine” on disk. It is a width, an , a signedness rule, maybe a scale, then a slot inside a that a WAL will mention before the data file does. Wrong representation is a correctness bug that looks like a storage engine bug.
This note is the layer under indexes and query plans: how bits become integers, floats, text, and records; why and scan differently; and how choices leak into latency, size, and schema evolution.
One field, four decisions
int cents, NUMERIC, or binary64 — not interchangeable.
Width and endianness must match every reader.
Padding, null bitmaps, and variable-length tails.
Row page vs column chunk vs object blob.
What actually happens
CPUs operate on registers of fixed width. Disks and networks move untyped octets. The is the agreement that those octets mean a particular value. System design interviews often jump to sharding; production outages equally often come from a Java int, a Postgres INTEGER, a protobuf int32, and a JSON number all silently disagreeing about range.
After the scalar encoding, the engine packs fields into a tuple: null bitmap, fixed columns, then a variable-length region (text, JSON, TOAST pointers). That tuple lands in a . Durability usually writes a log record first, then eventually the page or an immutable file. Representation choices (width, compression, column vs row) decide how many media bytes that one logical field becomes.
Read the diagram definition
flowchart TD
App["Application value\norder total = 19.99 USD"] --> Choose{"Choose representation"}
Choose -->|cents int64| I["Store 1999 as two's complement"]
Choose -->|IEEE 754| F["Store binary64 19.99\nrounding error"]
Choose -->|decimal| D["Store decimal128 / NUMERIC"]
I --> Ser["Serialize: endianness + width"]
F --> Ser
D --> Ser
Ser --> Page["Pack into a page / record"]
Page --> Media["WAL then data file / object"]Integers: width, signedness, endianness
Unsigned 32-bit holds 0…4,294,967,295. Signed 32-bit holds −2,147,483,648…2,147,483,647. A user id that “fits in an int” in the first million rows can overflow a signed 32-bit primary key years later. Auto-increment that started at 1 still hits the wall; migrating BIGINT under load is a storage project, not an ALTER that is free.
Value 0x01020304 (32-bit)
Big-endian bytes: 01 02 03 04 // network order, Java DataOutput
Little-endian bytes: 04 03 02 01 // x86/x64 memory, most WAL dumps
Signed 8-bit two's complement:
0000 0111 = 7
1111 1001 = -7 // invert 0000 0111 → 1111 1000, add 1
Varints (protobuf / SQLite):
small magnitudes use 1 byte; large use more
great for sparse small ids; worst-case wider than fixed 32-bitOn-disk engines pick one and document it. Never memcpy a C struct to the network. Decode through an explicit codec. When you copy pages between architectures, either the format is endian-neutral (byte-oriented varints, UTF-8) or you byte-swap at the boundary.
Floats, decimals, and time
| Kind | Typical encoding | Use | Failure mode |
|---|---|---|---|
| binary32 / binary64 | Sensors, rankings, ML scores | 0.1 + 0.2 ≠ 0.3; NaN/Inf poison aggregates | |
| Scaled integer | int64 cents / micros | Money, rates with fixed scale | Overflow; mixed scales (cents vs dollars) |
| DECIMAL / NUMERIC | Base-10 coefficient + scale | Ledger amounts that must round like humans | Wider storage; slower CPU than int64 |
| Timestamps | epoch micros, or civil fields + zone | Event time vs display time | TZ-naive columns; leap seconds; 32-bit epoch |
Store instants in UTC with a documented precision (milliseconds vs microseconds). Store the original zone only if you must reconstruct wall-clock intent (“meeting at 9:00 in Kolkata”). The 2038 problem is a width problem: signed 32-bit Unix time overflows. Treat time as another integer encoding with a named epoch, not as a string you sort lexicographically unless the format is ISO-8601 with a zero-padded fixed width.
Text, bytes, and Unicode
is the default on the web and in most databases. A VARCHAR(10) in one engine means 10 characters; in another it means 10 bytes. Emoji and combining marks make “character” itself slippery (code point vs grapheme cluster). Binary columns (BYTEA, blobs) must never pass through a text codec.
"é" U+00E9 UTF-8: C3 A9 (2 bytes)
"é" e + combining acute UTF-8: 65 CC 81 (3 bytes) // different bytes, may look equal
"😀" U+1F600 UTF-8: F0 9F 98 80 (4 bytes)
Index keys include the encoded bytes unless you normalize:
NFC vs NFD, case-fold, trim
Expression indexes on LOWER(email) exist because the stored encoding is exact bytes
Truncation:
cut at 255 bytes mid-code-point → invalid UTF-8
always truncate on code-point or grapheme boundariesCollation is a separate contract on top of encoding: how two strings compare. A unique index on names without a documented collation will surprise you the first time two visually equal strings collide or fail to. For keys (emails, slugs), store a normalized form and compare as bytes.
Record layout: nulls, variable length, overflow
Typical heap tuple (simplified):
[header: xmin/xmax or version | infomask | null bitmap]
[fixed fields in declared order, with alignment padding]
[offsets into variable region]
[varlena: text / jsonb / arrays]
NULL is not a sentinel value in the column bytes; it is a bit.
Missing vs empty string vs JSON null are three different encodings.
TOAST / overflow:
huge JSON or text stored out-of-line
main tuple keeps a pointer; extra I/O on SELECT of that column
columnar files instead cut the column into compressed chunksinside the tuple is why swapping column order in a struct can shrink rows. On-disk, many engines pack more densely than C structs. In memory (buffer pool), they may decompress into padded form. Count both sizes when you size cache: the page on disk is not always the bytes in RAM.
Row pages versus column chunks
Read the diagram definition
flowchart LR
subgraph Row["Row page"]
R1["row1: id,user,amt,ts,json"]
R2["row2: id,user,amt,ts,json"]
end
subgraph Col["Column chunks"]
C1["id,id,id,..."]
C2["amt,amt,amt,..."]
C3["ts,ts,ts,..."]
end
Q1["OLTP GET by id"] --> Row
Q2["SUM amt WHERE ts > T"] --> ColRow-oriented
- OLTP: fetch one order, all columns, one page if the row fits.
- Updates rewrite a tuple (or a new MVCC version) in a page.
- Compression is weaker because types mix inside the page.
- InnoDB, heap Postgres, most document _id lookups.
Column-oriented
- Analytics: SUM(amount) reads one column file plus a filter column.
- Dictionary + RLE + bit-packing shine on low-cardinality columns.
- A single-row UPDATE may rewrite a chunk or append a new version.
- Parquet/ORC data lakes; warehouse tables; some HTAP hybrids.
Hybrids exist: a row store with a column overlay for analytics, or a column store with a row-group size small enough that a point get is tolerable. The interview claim is not “columnar is faster.” It is “this access pattern reads N columns × M rows; layout decides which bytes move.”
Compression as a representation choice
Dictionary and run-length
A status column with five values becomes a dictionary of five strings plus a packed array of 3-bit codes. Runs of the same code collapse further with RLE. This is why columnar files shrink 10× on enums and barely shrink on high-entropy ids.
Prefix compression on sorted keys
Sorted index leaves share prefixes. Storing the prefix once per page raises entries per page — a representation trick that raises B+ tree fanout. Same idea as front-coding in search indexes.
Block codecs (LZ4, Zstd, Snappy)
General compressors need a block of similar bytes. Fine on 64–256 KiB Parquet pages; awkward on 8 KiB OLTP pages that mix types. Always measure CPU vs I/O: on NVMe, LZ4 often wins; on cold object storage, Zstd at higher levels often wins.
Delta and frame-of-reference
Sorted timestamps store first value plus small deltas. Frame-of-reference subtracts a block minimum so values fit in fewer bits. These encodings assume locality; random UUIDs as clustered keys defeat them.
Serialization on the wire and in logs
WAL records, Kafka payloads, and RPC bodies all serialize. Self-describing JSON is debuggable and expensive. Protobuf tags fields by number so you can add fields without breaking old readers if you follow compatibility rules. Avro often stores a schema id and dense binary values — excellent for data lakes, painful if you lose the schema registry.
| Format | Schema | Density | Evolve by |
|---|---|---|---|
| JSON / JSONB | In-band keys | Low | Add keys; beware type changes |
| Protobuf / Thrift | Field numbers | High | New field numbers; never reuse ids |
| Avro | Writer/reader schemas | High | Defaults; resolution rules |
| Columnar files | Footer schema | Highest with stats | Add columns; readers skip unknown |
Checksums belong in the representation: CRC32C per WAL record or Parquet page so a torn write is detected rather than decoded as a plausible integer. Representation without integrity is how silent corruption hides for months.
Pages, caches, and why size matters
An 8 KiB page holding 40 padded 200-byte rows is a different I/O economy than the same page holding 80 packed rows. Column chunks of 1 MiB change the unit again: great sequential scan, painful if you needed one row. SSD erase blocks and object-store request pricing add yet another grain.
Worked density (OLTP orders row ≈ 200 B logical):
C-struct with padding: ~256 B → ~32 rows / 8 KiB page
Packed tuple: ~210 B → ~38 rows / page
2x dictionary-compress status+country in columnar chunk:
amt column 8 B × 100k rows = 800 KB uncompressed
bit-pack + delta on sorted ts: often 2–4 B equivalent
Cache: buffer pool caches pages, not fields.
A wide JSON column on the same row-page pollutes cache for id lookups.
Split hot narrow row vs cold blob (TOAST or side table).Trade-offs to state clearly
Width vs future range. int32 is smaller and hotter in cache; BIGINT avoids a painful migration. Pick from growth math, not from “int feels enough.”
Exact vs approximate numeric. IEEE 754 is fast and compact; cents or DECIMAL are correct for money. Mixing them in one total is a representation bug.
Row vs column. Point-get latency versus scan bandwidth and compression. HTAP systems pay for both layouts or accept staleness on one path.
Self-describing vs schemaful serialization. JSON eases evolution and debugging; binary schemas cut CPU and bytes but need a compatibility process.
Compression vs update grain. Dense encodings assume similarity and larger blocks. Fine for immutable files; costly if every click updates one cell in a huge compressed chunk.
Choosing and explaining data representation
A complete explanation connects the mechanism to a requirement. Describe what improves when you introduce it, which new failure cases become possible, and how you would know that the trade-off is worthwhile.
Explain the core idea
Representation is the stack of contracts from typed values to bytes on a page: width, endianness, nulls, then layout.
Integers need a documented width and signedness; floats are not money; UTF-8 length is not character length.
Row layout serves point reads; columnar layout serves scans and compression. Serialization is the same problem on the wire.
When it belongs
You size rows, WAL, or object payloads and need bytes per record, not just QPS.
A design mixes languages, databases, and queues that each encode numbers and time differently.
You choose OLTP row storage versus a lake/warehouse columnar file for the same logical events.
How it fails
Silent overflow of 32-bit ids or Unix time; float money that does not sum.
Endian or UTF-8 truncation bugs that pass tests on ASCII fixtures.
Schema-incompatible protobuf field reuse or a lost Avro schema registry.
Trade-offs to weigh
Dense packed rows versus aligned structs that are easier to decode.
Columnar compression versus point-update cost.
JSON debuggability versus binary CPU and size.
Common misunderstandings
Treating JSON numbers as exact decimals.
Assuming VARCHAR(n) means the same unit in every engine.
Copying C structs to disk and calling it a file format.
Wrap-up
Name the encoding, the byte order, the record packing, and the page shape before you talk about indexes. Those four decisions set every later I/O and compatibility cost.
Primary sources
Key takeaway
Bytes are meaningless without an encoding. Choose width, signedness, text, and layout from the access pattern and the compatibility window — then put integrity checks next to the bytes so a torn write cannot look like a plausible total.