Select * From Everything


performance databases api-design

System Design Scenario

Select * From Everything

When one lazy query multiplied by 10K requests/second turns your database into a data courier for columns nobody reads

⏱ 12 min read 📐 Intermediate 🔒 Performance

It’s 3:14 AM on a Thursday and the PagerDuty alert reads db_connection_pool_saturated. You SSH into the primary PostgreSQL instance and run pg_stat_activity. There are 247 active queries, all identical: SELECT * FROM users WHERE active = true. The users table has 47 columns. The endpoint consuming this query needs three of them: id, name, and avatar_url. The other 44 columns - including a 4 KB preferences JSONB blob, a bio text field averaging 800 bytes, and an address column nobody has queried intentionally since 2021 - travel from disk to PostgreSQL shared buffers, across the network to your application server, get deserialized into objects, serialized back to JSON, and sent over the wire to a mobile client that immediately discards everything except those three fields.

Imagine ordering a pizza and the restaurant sends the entire menu with your delivery. Every single time. Ten thousand times per second.

The math is brutal. Each row weighs roughly 12 KB when fully hydrated. The three fields the client actually reads total about 200 bytes. At 10,000 requests per second, your database is shipping 120 MB/s of data when 2 MB/s would suffice. That’s 118 MB/s of pure waste - wasted disk I/O reading columns from heap pages, wasted memory in shared buffers caching data nobody reads, wasted CPU serializing JSONB blobs into wire format, wasted network bandwidth between your database and application tier.

The database isn’t slow. It’s doing exactly what you asked. You asked for everything, so it’s giving you everything. The problem isn’t the database’s performance - it’s that 94% of its effort produces data that gets immediately thrown away.

GitHub discovered this pattern in 2012 when they traced why their Rails API was consistently slower than expected. The ORM’s default behavior - User.find(id) - generated SELECT * on a table with dozens of columns including serialized preference hashes. Shopify’s engineering blog documented a similar finding: switching from SELECT * to explicit column lists on their highest-traffic product endpoints reduced PostgreSQL CPU usage by 35%.

This is the over-fetching problem.

Why This Happens

The root cause is a mismatch between the convenience API developers interact with and the actual cost of database operations. ORMs are the primary vector. When you write User.objects.get(id=123) in Django or User.find(123) in Rails, the generated SQL is SELECT * FROM users WHERE id = 123. The ORM doesn’t know which attributes you’ll access on the resulting object, so it fetches all of them defensively.

This made sense when tables had 8 columns and traffic was 50 requests per second. It stops making sense when tables accumulate years of feature additions and traffic grows three orders of magnitude.

The failure chain looks like this:

Developer writes: User.objects.filter(active=True)
ORM generates:   SELECT * FROM users WHERE active = true
PostgreSQL does:  Sequential scan → reads all 47 columns from heap pages
                  → fills shared_buffers with full rows
                  → serializes all columns to wire protocol
Network carries:  12 KB per row × 10,000 rows/sec = 120 MB/s
App server does:  Deserialize all 47 fields into memory objects
                  → serialize to JSON (still all 47 fields)
                  → GC pressure from short-lived large objects
Client receives:  12 KB JSON payload, reads 3 fields, discards rest

The damage compounds at every layer. PostgreSQL can’t use a covering index (which serves queries entirely from the index without touching heap pages) because SELECT * by definition requires the full row. The operating system page cache fills with complete 8 KB heap pages when only a fraction of each page contains relevant data. The application server’s garbage collector works overtime processing objects that are 94% waste. Network buffers carry 60x more data than necessary.

Core Insight

Over-fetching isn't a single point of waste - it's a tax applied at every layer of the stack. Fix it at the query level and you get compounding savings in I/O, memory, network, serialization, and GC pressure simultaneously.

The Naive Solution

The first instinct most engineers have is to add a cache. If the database is doing too much work, put Redis in front of it and serve repeated requests from memory. This approach treats the symptom (database load) while ignoring the disease (fetching data nobody needs).

Premium Content

Unlock the full article along with everything else in the archive — all in one place.

In-depth analysis Expert insights Full archive access
Unlock Full Article