feat(benchmarks): report PostgreSQL's own time next to the wall clock - #47
Merged
Conversation
A review of what the benchmarks actually measure, rather than whether they run. Three findings, all verified against 100k rows. **Wall-clock timings were mostly the client.** `SELECT * LIMIT 10000` takes 17.22 ms end to end but 0.31 ms inside PostgreSQL: 98% of the "result" was psycopg2 building tuples. Worse, server-side `SELECT *` is *faster* than the three-column projection (0.31 ms vs 0.46 ms) because returning a stored tuple needs no projection work — so the headline number pointed the opposite way to the database. measure() now also captures Execution Time from EXPLAIN (ANALYZE, TIMING OFF), and every table and chart reports both. **The index benchmark undersold indexes by 4x.** Adding a B-tree to `WHERE age = 35` improves the wall clock 2.1x but PostgreSQL's own time 7.7x; shipping ~1,600 wide rows costs the same however they were found. Both numbers are now visible, which is the actual lesson: an index speeds up finding rows, not sending them. **JOIN vs IN vs EXISTS compared three spellings of one plan.** The seed did `SELECT id FROM users WHERE random() < 0.3`, giving every user at most one order. With 1:1 data JOIN never fans out: all three returned identical row sets and IN/EXISTS produced byte-identical plans. Orders are now 1:N (up to 5 per user), so JOIN emits 225,245 rows against 95,077 — it is the fastest of the three on the server and the slowest overall, because it ships 2.4x more rows. README corrected throughout, including a claim of mine that the TEXT bio column "dominates the row width". It averages 17.9 bytes, 10.2% of the row. The real ratio is 143 vs 30 bytes, which is what the ~5x gap reflects. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014abw4B6YUf54giaEyPQpbo
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A review of what the benchmarks measure, rather than whether they run. Three findings, all verified against 100k rows.
1. The wall clock was mostly the client, not the database
SELECT * LIMIT 10000SELECT id, name, email LIMIT 1000098% of the headline result was psycopg2 building tuples. And server-side
SELECT *is faster than the three-column projection — returning a stored tuple needs no projection work, while picking three columns means building a new one. The number pointed the opposite way to the database it claimed to be about.measure()now also capturesExecution TimefromEXPLAIN (ANALYZE, TIMING OFF). Every table and chart reports both; solid lines are the total, dashed are PostgreSQL alone.SELECT *is still worth avoiding — but for the true reason, which is that it strains your application and your network, not the planner.2. The index benchmark undersold indexes by ~4x
Shipping ~1,600 wide rows costs the same however they were found, so
SELECT *swamps the lookup. A benchmark titled "why B-tree indexes are not optional" was showing 2.1x for a reason that has nothing to do with indexes. Both numbers are now visible, which is the real lesson: an index speeds up finding rows, not sending them.3. JOIN vs IN vs EXISTS compared three spellings of one plan
The seed did
SELECT id FROM users WHERE random() < 0.3— every user got at most one order. With 1:1 dataJOINnever fans out, so all three returned identical row sets (24,208) andIN/EXISTSproduced byte-identical plans (Hash Semi Join, cost875.51..4680.36). The distinction the benchmark exists to show was the one the data prevented.Orders are now 1:N (up to 5 per user, avg 2.58). At
amount > 50,JOINemits 225,245 rows against 95,077 — it is the fastest of the three on the server (40.8 vs 47.1 / 55.8 ms) and the slowest overall (160.6 vs 83.4 / 78.3 ms), because it ships 2.4x more rows.README
Corrected throughout, including a claim I introduced myself: that the
TEXTbio column "dominates the row width". It averages 17.9 bytes, 10.2% of the row. The real ratio is 143 vs 30 bytes, which is what the ~5x gap actually reflects.Verification
ruff check .clean, 60 tests (was 54), 92.8% coverage.🤖 Generated with Claude Code
https://claude.ai/code/session_014abw4B6YUf54giaEyPQpbo