SwadhyAI Notes

All System Design notes

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

1

int cents, NUMERIC, or binary64 — not interchangeable.

2

Width and endianness must match every reader.

3

Padding, null bitmaps, and variable-length tails.

4

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.

Diagram text: the exact Mermaid definition below preserves the nodes, labels, and connections. Enable JavaScript to view the rendered diagram.
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.

plaintext
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-bit

On-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

KindTypical encodingUseFailure mode
binary32 / binary64Sensors, rankings, ML scores0.1 + 0.2 ≠ 0.3; NaN/Inf poison aggregates
Scaled integerint64 cents / microsMoney, rates with fixed scaleOverflow; mixed scales (cents vs dollars)
DECIMAL / NUMERICBase-10 coefficient + scaleLedger amounts that must round like humansWider storage; slower CPU than int64
Timestampsepoch micros, or civil fields + zoneEvent time vs display timeTZ-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.

plaintext
"é"  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 boundaries

Collation 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

plaintext
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 chunks

inside 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

Diagram text: the exact Mermaid definition below preserves the nodes, labels, and connections. Enable JavaScript to view the rendered diagram.
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"] --> Col

Row-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.

FormatSchemaDensityEvolve by
JSON / JSONBIn-band keysLowAdd keys; beware type changes
Protobuf / ThriftField numbersHighNew field numbers; never reuse ids
AvroWriter/reader schemasHighDefaults; resolution rules
Columnar filesFooter schemaHighest with statsAdd 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.

plaintext
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.

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.