Chapter 9 — Key Takeaways (Data Lakes)

The page for designing a lake, or for diagnosing one that has gone wrong.

Object storage is not a filesystem

Difference What it costs you
No directories — a key is one opaque string "Rename a directory" = N copies + N deletes, not atomic. LIST is a paginated query, 1,000 keys per call.
Objects are immutable Updating a row means rewriting a file. This is why lakehouse table formats exist.
Operations are billed and rate-limited $0.0004/1,000 `GET`, $0.005/1,000 PUT. The small-files tax.
Consistency changed in 2020 S3 is now strongly read-after-write consistent. Pre-2021 workaround advice is obsolete and still ranks well.

Inside a Parquet file

PAR1 │ RowGroup 0 [ col chunk → pages, + min/max/null stats ] │ RowGroup 1 … │
     │ FOOTER: schema · row-group stats · column byte offsets │ len │ PAR1

Reader sequence: last 8 bytes → footer → skip row groups by statistics (predicate pushdown) → range-request only named column chunks (projection pushdown) → decode.

Footer at the end so the writer can stream without knowing statistics in advance. ⚠️ A truncated Parquet file is entirely unreadable — it fails cleanly rather than silently returning partial data.

Targets: row group 128 MB · file 128 MB – 1 GB, 256 MB default · zstd.

Layout

Separate buckets per layer — because access control is per bucket (the deciding reason), plus lifecycle policy, blast radius, and cost attribution.

s3://kestrel-bronze/orders/v1/ingest_date=2025-11-28/hour=03/part-00000.parquet
                    └─dataset └─schema version  └─Hive partition       └─ + _SUCCESS
  1. Dataset name first · 2. Schema version in the path (free to add, impossible to retrofit) ·
  2. Hive-style key=value · 4. Partition columns in descending selectivity · 5. _SUCCESS marker written last

📐 Bronze by ingest date; silver and gold by event date. Partitioning bronze by event date means a late arrival must modify an old partition — a non-atomic rewrite while people are reading. The write path gets the simple option.

Landing raw

Raw = exactly what the source sent + receipt metadata. Payload stays a STRING.

{"_ingested_at": "...", "_source": "kafka:...", "_partition": 7,
 "_offset": 412883901, "_schema_version": "v1", "payload": "{...}"}

Why unparsed: parsing at landing forces a decision about an unparseable record, and every answer loses information — drop it (gone), null it (original lost), fail the batch (availability).

Why keep it: reprocessing · investigation ("what did the source actually say?") · unanticipated questions · audit. At Kestrel: $7.84/month, so cost is not the argument.

🔐 The real argument against forever-retention is erasure, not cost. Three answers: a table format with row-level deletes (Ch. 10) · tokenize at landing (contradicts "unparsed", and right when the alternative is not landing at all) · short bronze retention. Decide before you land.

Partitioning

Kestrel clickstream, 341 GB/year, 66.7 bytes/event:

Grain Partitions Bytes each
Monthly 12 28.4 GB
Daily 365 934 MB ← right
Hourly 8,760 38.9 MB
By minute 525,600 649 KB

⚠️ Never partition by a high-cardinality column. customer_id → 1.9M partitions → 1,900 LIST calls before reading a byte, $0.76/query in requests, and a catalog that degrades.

Partition by the low-cardinality column you filter on; use SORT ORDER within the partition for the high-cardinality one.

The small-files problem

30-second commits × 12 partitions:

$$\frac{86{,}400}{30} \times 12 = 34{,}560 \text{ files/day} = 12.6\text{M/year at 27 KB}$$

Cost Amount
Requests, full scan $5.05 ← the number people quote
Latency, 100-way parallel 63 minutesthe actual cost, on no invoice
Same bytes, 1,332 files ~30 seconds
Compression loss 27 KB has no patterns to exploit

Compact when: avg file < 32 MB · files/partition > 1,000. Leave alone at 128 MB+.

Three properties of a good compaction job: 1. It sorts — free at write time, improves compression and pruning. Often the larger win. 2. It writes then swaps. Never delete first. 3. It runs on a lag — yesterday's partition, not today's.

Two-tier is the standard answer: small files for freshness, nightly compaction for scans.

⚠️ There is no atomic multi-object operation

Every protocol on plain object storage has a window. Make it small, detectable, and loud:

  • Marker last — protects against partial writes, not partial replacements
  • Version the prefix + flip a pointer object — the flip is one object write, which IS atomic. This is the pattern that works, and it is a hand-rolled worse version of a transaction log.
  • Verify the row count before the flip. A compaction that loses rows and commits destroys its own input.
  • Never delete before writing. Old files cost storage; a partial partition costs correctness.

Lake vs. swamp — seven practices

Practice What a database did for you
1. Layout convention, enforced schemas, table names
2. Versioned schema per dataset CREATE TABLE
3. Catalog: what, who, what it means information_schema + the DBA
4. Quality checks at the boundary constraints, types, NOT NULL
5. Retention, enforced automatically always yours
6. Compaction and layout maintenance the storage engine
7. Access control by layer and column GRANT

All seven are invisible for eighteen months. The cost arrives as ambiguity, not failure.

Make ownership a precondition for existence, enforced by policy. A catalog nobody is required to fill in stays empty. One audit: 112 of 200 datasets had no answer to "who owns this"; 96 were archived and nobody noticed.

"Nothing is reading it" is a claim about your observation window. 30 days of logs cannot see a quarterly job. Archive with access revoked and let the failure reclaim it — converts an unanswerable question into a cheap, self-resolving one.

Querying a lake — ascending guarantees

Method Gives you
Direct file access nothing. Fine for exploration, a liability as an interface
A catalog over files the minimum viable interface for other consumers
A table format (Delta/Iceberg) atomic commits, schema enforcement, time travel, row deletes → Ch. 10

The four numbers to know about any object-storage dataset

Object count · total size · average file size · partition count.

Three lines of Python, and they diagnose a class of problem no query profile surfaces. Superlinear scaling in a scan means the cost is per-something-else — count, and find out which.