Less I/O

A simple mental model that sits underneath almost every data-pipeline optimization. The cheapest byte to process is the one you never had to move.

#data-engineering #performance #spark #clickhouse #parquet #ai

Here's the whole post in one breath: nearly every optimization you'll ever reach for is a version of one idea β€” Less I/O β€” move and store less data. And you rarely build it yourself. Run one ordinary query and dozens of inherited optimizations fire beneath you β€” the planner skips files, the format reads a single column, the CPU cache keeps the hot bytes a nanosecond away β€” each invented by people you'll never meet, over decades. Every one of them is doing the same thing.

ONE QUERY β†’ A WHOLE STACK OF INHERITED OPTIMIZATION one query β€” every layer fires inherited Β· mostly invisible SELECT sum(amount) FROM tx WHERE day = today ← what you wrote Query planner partition pruning + predicate pushdown β†’ skip whole files Columnar format (Parquet / ORC) read only the columns asked for Β· dictionary/RLE Β· page min-max skip Vectorized execution (SIMD) process whole columns per instruction, not value-by-value Distributed scheduler data locality: ship the code to the data, not the data to the code OS page cache read-ahead / prefetch the next sequential block before you ask Storage layout sequential ≫ random Β· partitions, sort keys, big row groups CPU cache hierarchy L1 / L2 / L3 + hardware prefetcher keep hot bytes a nanosecond away Less I/O store fewer Β· move fewer Β· keep them close & sequential
Layer upon layer, one idea. Every band was someone's breakthrough β€” Dremel and Parquet, Hadoop's locality, vectorized engines, decades of OS and CPU work. You inherit all of it for free. Read the layers top to bottom and they blur into a single sentence: skip files, read fewer columns, touch data less, avoid the network, prefetch, stay sequential, stay in cache β€” every one of them is Less I/O.

In practice, that one idea just wears three hats:

  • Data movement: ship code to data, vectorize, prune partitions β†’ move fewer bytes.
  • Data storage: columnar layout, Parquet encodings, tight ClickHouse types β†’ store fewer bytes.
  • Data model: pre-aggregate, denormalize for the query, partition by access pattern β†’ look at fewer bytes.

That's the entire idea β€” skim the diagram and you've got the gist. The rest of this post is the tour: why moving data is so expensive, and how each layer actually pulls it off, with examples from Hadoop, SIMD, Parquet, ClickHouse, a banking data model, a Spark crash that turned on this exact principle β€” and an honest caveat on the rare day you're CPU-bound, not I/O-bound.

01The cost of moving a byte

Here is the one idea that powers everything else. A byte of data can live in many places β€” a CPU register, the tiny L1 cache, the larger L2 and L3 caches, main memory (RAM), a local SSD, or across the network on another machine's disk. Computing on that byte requires bringing it close to the CPU.

The catch: the cost of fetching a byte grows by orders of magnitude β€” in big multiplicative jumps β€” the further away it lives. Not 2Γ— further, not 10Γ— β€” often thousands to millions of times slower. This isn't a minor tuning detail; it's the dominant force in pipeline performance.

FARTHER FROM THE CPU β†’ ORDERS OF MAGNITUDE SLOWER L1 cache ~1 ns 1Γ— L2 cache ~4 ns 4Γ— L3 cache ~14 ns ~14Γ— RAM ~100 ns ~100Γ— SSD (NVMe) ~16 Β΅s ~16,000Γ— Network (DC) ~0.5 ms ~500,000Γ— Disk (HDD seek) ~10 ms ~10,000,000Γ— Internet (RT) ~150 ms ~150,000,000Γ— slower than L1
The hierarchy of pain. Bars use a log scale (so the small ones stay visible) but the "Γ— vs L1" column is the real story: each step out is a multiplicative jump, not an additive one. Numbers are rough, order-of-magnitude figures.

Log-scaled bars are honest but they hide the shock. So here's the same data rescaled to human time: imagine an L1 cache hit takes 1 second. Everything else stretches to match.

Fetch from…Real latencyIf L1 = 1 second
L1 cache~1 ns1 second
RAM~100 ns~100 seconds
SSD~16 Β΅s~4.5 hours
Network (same datacenter)~0.5 ms~6 days
Disk seek (HDD)~10 ms~4 months
Internet round trip~150 ms~5 years
The whole principle in one line

If reading from cache feels like grabbing a pen off your desk, reading from a remote disk feels like waiting a season for it to arrive. So: don't move data you don't have to. Keep what you do move small, close, and sequential. Everything below is a corollary of this.

02Less I/O in data movement

The first lever is the obvious one: physically move fewer bytes, over shorter distances, more efficiently. Three classic techniques.

Data locality β€” move the code to the data

Code is tiny. Data is huge. So when Hadoop pioneered "big data," its key insight was to ship the computation (a few KB of code) to the machines that already hold the data (gigabytes on local disk) β€” instead of streaming terabytes across the network to a central compute node. Spark, Presto, and every modern engine inherit this idea.

βœ• Move data β†’ compute node A node B node C COMPUTE network = πŸ”₯ Terabytes cross the wire. Network is the bottleneck. βœ“ Move compute β†’ data code ~few KB node A node B node C Each node computes on its own local blocks. Only tiny results travel.
Data locality. Hadoop's founding trick: shipping kilobytes of code beats shipping terabytes of data. The same principle is why a GROUP BY that pre-aggregates on each worker (a "combiner" / partial aggregate) beats one that shuffles every row to one place.

Vectorization (SIMD) β€” touch the data once, do more per touch

Once data is in the CPU, there's still overhead in processing it one value at a time β€” every operation carries a per-instruction cost. SIMD (Single Instruction, Multiple Data) amortizes that cost by letting one instruction operate on a whole batch of values β€” 4, 8, 16 at a time β€” instead of one. Columnar engines love this: a column is just a tight array of identical-type values, which is exactly what SIMD wants to chew through.

Scalar β€” 8 instructions, one value each aβ‚€ + bβ‚€ β†’ cβ‚€ tick 1 tick 2 β†’ c₁  Β·  tick 3 β†’ cβ‚‚  Β·  …  Β·  tick 8 β†’ c₇ (8 separate clock ticks) SIMD β€” 1 instruction, 8 values at once aβ‚€a₁aβ‚‚a₃aβ‚„aβ‚…a₆a₇ + bβ‚€b₁bβ‚‚b₃bβ‚„bβ‚…b₆b₇ = One add instruction, one tick β†’ c₀…c₇ all computed. ~8Γ— the work per "touch" of the data.
SIMD / vectorization. Processing a column as a packed array lets the CPU amortize instruction overhead across many values. Engines like ClickHouse, DuckDB, and Spark's Photon/Tungsten are built around this.

Partition pruning β€” never read what you can skip

The cheapest read is the one that never happens. If a table is physically partitioned by, say, event_date, then a query filtered to one day can skip entire folders of files. The engine reads metadata, realizes 11 of 12 months are irrelevant, and never opens them.

SELECT sum(amount) FROM tx WHERE month = '2026-06' 2026-01 ‫ 2026-02 ‫ 2026-03 ‫ 2026-04 ‫ 2026-05 ‫ 2026-06 βœ“ 2026-07 ‫ 2026-08 ‫ 2026-09 ‫ 2026-10 ‫ 2026-11 ‫ 2026-12 ‫ Read 1 of 12 partitions β†’ ~92% of the I/O skipped before it ever starts.
Partition pruning. Partition (and the finer-grained "min/max" statistics inside Parquet/ORC files) let the engine eliminate data using cheap metadata. Choosing a partition key that matches how you actually filter is one of the highest-leverage design decisions you'll make.

03Less I/O in data storage

If movement is about where bytes go, storage is about how many bytes exist β€” and, just as important, in what order you can read them. Two physical facts run this section: reading sequentially is far cheaper than reading randomly, and reading only the columns you need beats reading whole rows. Which layout actually wins depends entirely on how you query.

Sequential reads beat random reads β€” by 10–100Γ—

Before columns vs rows, there's a more primitive fact. A sequential read streams contiguous bytes: the device does one big transfer, the OS prefetches ahead, and the per-request overhead is amortized across megabytes. A random read scatters tiny fetches across the medium β€” on a spinning disk every jump pays a ~10 ms seek, so the penalty is brutal (often 100Γ—+); on an SSD there's no seek, but you still lose prefetching and pay per-operation overhead, so it's smaller but real (~2–10Γ—). Across the spectrum, expect one to two orders of magnitude.

βœ“ Sequential β€” stream one contiguous run One transfer Β· the OS prefetches ahead Β· per-request cost amortized over megabytes. HDD ~150 MB/s  Β·  NVMe ~3–7 GB/s βœ• Random β€” scattered reads, a seek each Each jump = a seek (HDD ~10 ms) + lost prefetch. HDD ~1–2 MB/s at small reads (~100Γ—); SSD smaller (~2–10Γ—).
Sequential vs random I/O. The single most important physical fact in storage: contiguous reads run 10–100Γ— faster than scattered ones. Every layout trick below β€” columnar stripes, sorted/clustered data, big row groups β€” exists to turn your query into one long sequential scan instead of a million little seeks.

So the whole game of physical storage is one move: turn your dominant access pattern into a long sequential scan of just the bytes you need. Partitioning, sort/clustering keys, big row groups, and the row-vs-columnar choice are all ways to play it.

Row vs columnar: it depends on the access pattern

There's no universal winner here β€” it's a battle decided by usage, and each format is really just trying to make its kind of access sequential:

  • Columnar wins for analytics (OLAP). Queries are narrow and tall β€” a few columns across millions of rows. Storing each column contiguously turns a 2-of-20-column query into a couple of long sequential scans, skips the other 18 columns entirely, and compresses each one hard because its values are homogeneous.
  • Row wins for transactions (OLTP). Queries are wide and short β€” "fetch or update this one customer's whole record." Storing the full row contiguously means a single sequential read grabs every field at once; a columnar store would have to gather that row from 20 scattered stripes β€” random I/O, the slow path.
Row storage β€” query reads everything id name ccy amount Need ccy, amount? You still pull id and name off disk too. Columnar β€” read just 2 stripes id name ccy βœ“ amount βœ“ Only the green stripes are read. Bonus: each stripe is one type β†’ compresses hard. Why columns also compress better A column holds values of one type with low variety β€” e.g. currency is almost always "VND". Similar values sit next to each other, so dictionary + run-length encoding crush them. Less bytes = less I/O.
Row vs columnar β€” the analytics case. Here columnar storage is a Less-I/O machine on two fronts: it reads fewer columns and compresses each one far better because the data is homogeneous. Flip the workload to single-row lookups and the advantage flips too β€” see the table.
DimensionRow storeColumnar store
Best forOLTP β€” point lookups, row reads/writesOLAP β€” scans, aggregations, reports
Fetch one full rowone sequential readgather from N scattered stripes
One column over a billion rowsdrags every column throughone long sequential scan
Single-row updatecheap, in placecostly β€” rewrite column chunks
Compressionmodest (mixed types per row)strong (one type per column)
ExamplesPostgres, MySQL/InnoDBParquet/ORC files, ClickHouse, BigQuery

A columnar column-scan and a row-store point lookup are, deep down, the same move: read one contiguous run instead of seeking all over. They just optimize for opposite shapes of query. Most data pipelines feed analytics, so the rest of this section lives in columnar land β€” but if your workload is point lookups and frequent updates, a row store (or a key-value store) is the Less-I/O choice.

Parquet, concretely β€” the clever bits

Parquet is the workhorse columnar format, and it is clever in two complementary ways: it shrinks the bytes (encoding + compression) and it skips the bytes (statistics, pages, and clustering). The layout is a set of nested boxes, and every box is a chance to do one or the other:

Nested layout β€” each level lets the engine skip or compress Parquet file Row group (e.g. 128 MB of rows) col: amount page Β· min/max page Β· min/max col: ccy col: date Footer β€” schema + statistics for every column chunk Engine reads this FIRST, then skips groups/pages whose min/max can't match the filter. Encodings that shrink a page Dictionary encoding ["VND","VND","USD","VND","VND"] becomes dict 0=VND 1=USD codes 0 0 1 0 0 Strings stored once; rows become tiny integers. Run-length encoding (RLE) 0 0 0 0 0 0 1 1 becomes (0 Γ—6) (1 Γ—2) Long repeats collapse to almost nothing. The combo Partition prune β†’ skip files. Row-group / page min-max β†’ skip chunks. Dictionary + RLE β†’ shrink what's left. Read narrow columns β†’ touch only what you asked for.
Parquet anatomy. Every layer β€” file, row group, column chunk, page, footer statistics β€” exists so the engine can read less. Good Parquet hygiene (sorted data, sensible row-group size, dictionary-friendly columns) can cut scan volume by an order of magnitude.

Two stages, in order: encode, then compress. People blur these together, but Parquet does them as distinct steps. Encoding re-expresses values using their structure β€” a dictionary for repeated strings, run-length for repeats, delta for slowly-changing numbers, bit-packing to spend only as many bits as a value needs. Compression then runs a general byte-squeezer (Snappy, ZSTD, gzip) over the already-encoded bytes. Encoding understands your data; compression doesn't care what the bytes mean.

Two stages, in order raw values 1700000, 1700005, … encode encoded dictionary Β· RLE Β· delta Β· bit-pack compress compressed bytes β†’ disk Snappy / ZSTD / gzip structural understands the values squeezes bytes, blind Delta encoding β€” perfect for sorted ids & timestamps 1700000 1700005 1700007 1700012 1700015 store base + gaps β†’ 1700000 +5 +2 +5 +3 Tiny gaps β†’ a handful of bits each (bit-packed). The more sorted the column, the smaller it gets.
Encode, then compress. The structural step is where the big wins hide β€” dictionary, run-length, and delta turn real-world columns (low-variety strings, sorted keys, timestamps) into nearly nothing before a general codec even runs.

Then a codec squeezes what's left. The trade, stated honestly: you spend CPU time to buy back I/O time. On an I/O-bound scan that's almost a free lunch β€” the CPU is idle waiting on the disk or network anyway, so the cycles spent decompressing hide behind the wait, and the job finishes sooner because there were fewer bytes to move. The right codec depends on which resource is actually scarce:

CodecRatioSpeedReach for it when…
Snappy / LZ4modestvery fasthot data, CPU-bound jobs (a common default)
ZSTDstrong, tunablefast at low levelsthe modern all-rounder β€” best size/speed balance
Gzipstrongslowcold / archival data where bytes matter most

For big analytics scans you're almost always I/O-bound, so ZSTD is the usual sweet spot: a strong ratio with decompression fast enough to keep the scan fed.

Pages β€” the unit of skipping. Inside a column chunk, values are split into pages (~1 MB each, plus a dictionary page). Every page records min/max stats, and modern Parquet writes a separate page index (a column index + offset index, kept in the footer) so a reader can consult per-page min/max without touching the pages, then seek straight to the few that can match β€” pruning one level finer than the row group. Sorted data makes this lethal: tight, non-overlapping ranges mean a selective filter opens a handful of pages out of thousands.

And here's the part that makes Parquet genuinely elegant: the page isn't just the unit of skipping β€” it's the unit of compression too. Each page is encoded and compressed on its own, so once the page index has pruned down to the few pages that can match, those are the only pages the reader decompresses. Skip and un-squeeze happen at the same fine grain β€” you never pay CPU to decompress bytes you were always going to throw away. That alignment is exactly why the CPU-for-I/O trade above stays cheap in practice.

Z-order β€” pruning on more than one column

Min/max pruning only works if the values you filter on are clustered. Sort a file by account_id and filters on account_id fly β€” but a filter on date now reads everything, because every page holds the full range of dates. A single sort key only clusters the first column; everything after it is scattered.

Z-ordering (a Morton-order space-filling curve) fixes that. It interleaves the bits of several columns into one ordering, so rows that are near each other in any of those dimensions tend to land in the same row-group. You give up the perfect clustering of a single sort key in exchange for good clustering on several columns at once β€” exactly what you want when different queries filter on different fields. Lakehouse table formats (Delta Lake, Iceberg, Hudi) apply it when they write the underlying Parquet.

Sorted by A only row-groups = vertical stripes filter: B ≀ median A β†’ B ↑ β†’ read 4 / 4 row-groups Z-ordered by (A, B) row-groups = square blocks skipped β€” min(B) > median filter: B ≀ median A β†’ B ↑ β†’ read 2 / 4 row-groups
Z-order vs a single sort key. Sorting by A alone makes A-filters cheap but leaves B scattered across every row-group (left: a B-filter reads all four). Z-order interleaves both, so each row-group covers a compact square of (A, B) β€” a filter on either column prunes well (right: two of four). You trade the peak performance of one sort key for balanced pruning across several β€” which is why it exists for multi-column filter patterns.

ClickHouse types β€” pick the smallest type that fits

ClickHouse takes the "fewer bytes" idea to the column-type level. The data type you choose directly sets how many bytes each value costs on disk and in memory. A few habits that quietly halve (or 8Γ—) storage:

Smaller type = fewer bytes per row = less I/O  (bars = bytes/value, 35 px = 1 B) store a timestamp DateTime64 β€” 8 B DateTime β€” 4 B Date β€” 2 B Pick the coarsest precision that fits β€” day-level? use Date. Don't pay for nanoseconds you don't need. an id ≀ 4 billion Int64 β€” 8 B UInt32 β€” 4 B Right-size integers; UInt8/16/32 cover most real ranges. currency code String "VND" β€” ~4 B/row (3 B text + length) LowCardinality(String) β€” ~1 B code (+ shared dict) LowCardinality builds a per-column dictionary: store "VND" once, rows reference a tiny code. Rule of thumb: every byte you remove from a hot column is multiplied by billions of rows β€” and read on every query.
Type discipline in ClickHouse. LowCardinality, right-sized integers, Date / DateTime / DateTime64, and codecs like Delta + ZSTD on sorted columns are the bread-and-butter of a small, fast table. Smaller columns also mean more rows fit in cache β€” Less I/O all the way down.

04Less I/O in the data model

This is the lever most people underuse β€” and it has the highest ceiling. Movement and storage tricks shave a query; the data model decides how much data the query ever needs to look at. In banking, where tables of transactions run into the billions of rows, modeling for Less I/O is the difference between a dashboard that loads in 200 ms and one that times out.

The principle: model for the question you'll actually ask, not for textbook normalization. Normalization is wonderful for transactional correctness; analytics often wants the opposite β€” pre-joined, pre-aggregated, partitioned by how you filter.

Question: "show this account's end-of-day balance for the last 90 days" βœ• Compute from raw ledger every time transactions~3,000,000,000 rows β‹ˆ join accountsjoin + sort + window Scan billions, join, running-sum per account, filter to 90 days. ~120 GB scanned β†’ βœ“ Read a pre-aggregated daily snapshot daily_balance (account, day, balance)partitioned by month Β· sorted by account Built once by a nightly job. Query = prune to 3 months + point-lookup the account. ~90 rows read The trade you're making β€’ Do the heavy aggregation once, off-peak β€” not on every dashboard load by every user. β€’ Spend a little storage (the snapshot table) to save enormous, repeated read I/O. β€’ Partition + sort keys chosen to match the filter (by day, by account) so pruning does the rest.
Model for the query. The same answer, two data models. Pre-aggregation and a partition/sort key that mirrors the access pattern turn a multi-billion-row scan into a handful of rows. In banking β€” balances, daily positions, regulatory aggregates β€” this pattern is everywhere.

The modeling moves that buy the most

A handful of patterns I reach for again and again β€” all variations of "decide the answer ahead of time":

  • Incremental materialized views β€” keep an aggregate up to date as data arrives instead of recomputing from scratch (ClickHouse's AggregatingMergeTree, dbt incremental models, Spark Structured Streaming).
  • Sort key = scan/filter order β€” physically ordering a table by the columns you filter on lets min/max statistics prune aggressively. A balance table sorted by (account_id, day) turns a lookup into a near-point read.
  • Denormalize the hot path β€” fold the one or two dimension columns a query always needs straight into the fact table, so the frequent query never pays for a join.
  • Roll up by grain β€” keep daily summaries, then monthly ones; a "last 2 years by month" report reads ~24 rows instead of two years of raw transactions.
A note on judgment

Pre-aggregation isn't free: snapshots add storage, pipeline complexity, and a freshness lag. The skill is knowing which questions are asked often enough to deserve their own model β€” and which are rare enough to compute on demand. That's a modeling decision, not a tuning flag.

05When the work funnels to one node β€” a Spark story

Everything so far has been about spreading work out and reading less. This is the failure mode that breaks both at once: data quietly funneling onto a single node. And it's the reason I finally sat down to write this.

I'd been meaning to for a while β€” five years as a data engineer, and now from the other side of the table as a solution architect, where I mostly watch the same handful of principles decide whether a system flies or crawls. The actual nudge came from a junior DE on my team.

His Spark job kept dying with an out-of-memory error on the driver. He'd asked Claude Code, and β€” credit where it's due β€” it pointed straight at the culprit in seconds: a broadcast join. Disable it (drop spark.sql.autoBroadcastJoinThreshold to -1) and the crash disappears.

The answer was right, and it was fast. The problem was that nobody believed it. By every number the team could see, the broadcast side was only ~10 MB β€” right around Spark's default auto-broadcast threshold, and laughably small for a driver with gigabytes of heap. How could broadcasting 10 MB possibly OOM the driver? The suggestion looked obviously wrong, so they kept bumping driver memory instead β€” and kept crashing.

βœ• Broadcast the "small" side β†’ built on the driver first exec 1 exec 2 exec 3 DRIVER one JVM heap πŸ’₯ OutOfMemory Spark gathers + deserializes the "small" table on the driver. A 10 MB estimate can be 10Γ—+ in heap β†’ OOM. βœ“ Disable broadcast β†’ distributed sort-merge join exec 1 exec 2 exec 3 storageparquet No giant object on the driver. Executors shuffle + join in parallel; the driver just coordinates.
The driver is a single point of I/O. A broadcast join builds the "small" side on the driver before shipping it to executors β€” so an under-estimated size silently funnels a large object onto one heap. collect() and toPandas() do the same thing more openly. The driver should coordinate, not accumulate.

Then another engineer stepped in with the missing piece β€” and it's genuinely fundamental: that "10 MB" is an estimate, not a measurement. Because Spark evaluates lazily, at planning time it hadn't yet built the relation it was about to broadcast. Catalyst propagates an estimated sizeInBytes through the logical plan and compares that guess against the threshold. After a chain of lazy transformations β€” reads, filters, joins β€” the estimate can drift far from reality. And even when it's about right on disk, Parquet is compressed: once the relation is collected to the driver, decompressed, and deserialized into JVM objects (with their per-row and per-object overhead), the real heap footprint can be many times the "10 MB" the planner reported.

# Why Spark broadcast it β€” and why "10 MB" lied
spark.sql.autoBroadcastJoinThreshold = 10MB     # default: broadcast a join side whose ESTIMATED size is under this

# The dim side's plan-time ESTIMATE was ~10 MB  β†’ Spark auto-broadcast it.
# That estimate is heuristic β€” the planner sized it from stats, not the materialized object.
# On the driver: decompress + deserialize into JVM objects β†’ manyΓ— bigger β†’ OOM.

# βœ“ The fix:
spark.conf.set("spark.sql.autoBroadcastJoinThreshold", -1)   # disable β†’ distributed sort-merge join, nothing funnels to the driver

So the fix was right and the metadata was misleading. Disabling the broadcast β€” so Spark falls back to a distributed sort-merge join that never funnels the table through one node β€” is exactly the correct move. But you can only trust that move if you understand that the broadcast decision rides on a lazily-estimated size, not the real one. Without that fundamental, a correct answer looks like a bad guess, and you talk yourself out of it.

Two footnotes for the Spark-deep: newer engines re-check join sizes at runtime via Adaptive Query Execution β€” but it still sizes from compressed shuffle data, so the driver-heap blow-up can survive. And if the code carries an explicit broadcast() hint, it overrides the threshold entirely β€” then the fix is to remove the hint, not tune the config. (The deeper root cause: missing column statistics, which a COMPUTE STATISTICS / cost-based-optimizer pass would have sharpened.)

The other classic driver-OOM is the same sin worn differently β€” collect() or toPandas() pulling a big distributed result onto the driver:

from pyspark.sql.functions import col, sum

# βœ• BAD β€” pulls every matching row onto the driver's single heap
rows = (spark.read.parquet("s3://bank/transactions")
              .filter(col("txn_date") >= "2026-01-01")
              .collect())                       # all partitions β†’ driver β†’ OOM

# βœ“ GOOD β€” keep the reduction distributed; the driver only coordinates
(spark.read.parquet("s3://bank/transactions")
      .filter(col("txn_date") >= "2026-01-01")    # predicate pushdown (+ partition pruning if partitioned by date)
      .groupBy("account_id").agg(sum("amount"))   # reduced on the executors
      .write.parquet("s3://bank/daily_agg"))      # each executor writes its own share

The cure for the crash was pure Less I/O: stop funneling the table onto one node. But the reason it stalled wasn't the fix β€” it was that "is this answer actually right?" is a question only fundamentals can settle, especially when the surface signals (a reassuring "10 MB") point the other way. A correct fix that contradicts a comforting metric gets ignored by a team without the fundamentals to explain that metric away.

And honestly, that's the moment that made me want to write all this down. The AI was fast β€” it surfaced the right fix in seconds, faster than most engineers would. But "fast" isn't the same as "trusted." The fix sat ignored because the team couldn't yet explain why a 10 MB estimate could blow up a multi-gigabyte heap. The thing that finally unblocked them wasn't more compute or a better prompt β€” it was an engineer who held the fundamentals and could say, with confidence, "the estimate is lying, and here's the mechanism."

That's the shape of the job now, the way I see it: AI gives you candidate answers at remarkable speed; fundamentals are what let you accept the right one you didn't expect β€” and reject the confident one that's wrong. The faster the assistant gets, the more valuable the engineer who can adjudicate it becomes. Tools change every year; the principles under them β€” like Less I/O β€” barely move in a decade. That's where I'd still tell any DE to invest.

06When it's not I/O β€” the honest caveat

I lean on Less I/O as the default lens for one empirical reason: nearly every slow pipeline I've actually touched has been I/O-bound β€” waiting on bytes, not on math. But the exception deserves a name, because when it hits, the cure is the opposite.

Occasionally you flip to CPU-bound β€” the bytes arrive fast enough, and the bottleneck is the work done per byte. The usual culprits:

  • Compression turned up too high β€” gzip-9 or a high ZSTD level shrinks storage beautifully, then bottlenecks the scan on decompression. You bought fewer bytes and paid for them in CPU.
  • Heavy per-row work β€” complex UDFs, regex, JSON parsing, hashing β€” especially in Python, where you fall off the vectorized path.
  • Tiny data, big logic β€” once the dataset fits in cache there's no I/O left to remove; you're simply computing.

The tell is cheap to read: watch where the time goes. High disk/network wait β†’ I/O-bound, reach for everything above. Pinned CPU with the disk idle β†’ stop removing bytes and start removing work β€” a lighter codec, a vectorized expression, push the logic into the engine instead of a row-by-row UDF.

Why Less I/O is still the default

Not because CPU-bound never happens β€” but because, for the analytics and banking workloads I live in, it's the rare day. Most days, the bottleneck is the wire. Reach for the I/O lens first; only when the CPU is pinned and the disk is idle do you flip the question.


A thousand knobs, one lens

Here's the trap the surface area sets. Every layer you've seen comes with its own pile of knobs β€” codec and compression level, row-group and page size, partition key, sort key, Z-order columns, join strategy, broadcast threshold, column types, materialized views. Hundreds of settings across a dozen tools, each with its own docs and folklore, and a new one landing every release. You could spend a career memorizing them and still meet a stranger tomorrow.

You don't have to β€” because they all rhyme. When a layer misfires β€” a broadcast that shouldn't have been, a partition key that doesn't match how you filter, a collect() that drags the world to one node β€” the symptom looks different every time, but the cure is always the same: find where data is moving that it didn't need to, and take that work away. The knobs are just a thousand specific answers; Less I/O is the one question that tells you which knob to reach for, and why.

That's the case for carrying a mental model instead of a checklist: a checklist grows with the number of tools, a principle doesn't. The tools will keep changing β€” new formats, new engines, an AI that hands you the fix in seconds. The lens that tells you whether to trust that fix won't. So next time you're staring at a slow pipeline and don't know where to start, skip the knob-hunt and ask the only question that has ever mattered: where am I moving data I didn't need to β€” and how do I move less of it?