The query we're running
We want to know how many rides each driver completed and their total revenue. Step through to see what PostgreSQL does internally to produce the answer.
Read all matching rows from the table
PostgreSQL starts by reading every row from the rides table — raw and unsorted, exactly as they sit on disk. No grouping yet.
| driver_name | fare_amount | status |
|---|---|---|
| Ramesh Shrestha | 350.00 | completed |
| Sita Rai | 210.00 | completed |
| Ramesh Shrestha | 480.00 | completed |
| Bikash Thapa | 320.00 | cancelled |
| Sita Rai | 290.00 | completed |
| Ramesh Shrestha | 150.00 | completed |
| Bikash Thapa | 410.00 | completed |
Route each row into a bucket by hashing the key
As PostgreSQL scans each row, it computes hash(driver_name) and uses that value to find — or create — the matching bucket in memory. This strategy is called a HashAggregate. Rows go straight to their bucket — no sorting needed first.
| driver_name | fare_amount | hash(driver_name) |
|---|---|---|
| Ramesh Shrestha | 350.00 | → bucket R |
| Sita Rai | 210.00 | → bucket S |
| Ramesh Shrestha | 480.00 | → bucket R |
| Bikash Thapa | 320.00 | → bucket B |
| Sita Rai | 290.00 | → bucket S |
| Ramesh Shrestha | 150.00 | → bucket R |
| Bikash Thapa | 410.00 | → bucket B |
hash(driver_name) on the fly and dropped each row into its bucket as it scanned past.
▶ When does PostgreSQL sort instead of hashing?
- An index already returns rows pre-sorted by the GROUP BY column — sorting is then free, so PostgreSQL just streams matching rows into each group as it scans.
- The query also needs
ORDER BYon the same column(s) — one sort can satisfy both the grouping and the final order. - There are so many distinct groups that the hash table would exceed
work_mem— sorting and streaming avoids keeping all groups in memory at once.
EXPLAIN — look for HashAggregate vs GroupAggregate in the output.
Each bucket holds every row for one group
After one pass over the table, PostgreSQL has built three in-memory buckets — one per distinct driver_name hash value. Each bucket holds every row that hashed into it. Aggregate functions run next, once per bucket.
Run aggregate functions once per bucket
PostgreSQL runs COUNT(*) and SUM(fare_amount) on each bucket independently. Every bucket collapses into exactly one output row — this is where many rows become one summary row per group.
| driver_name | rides (COUNT) | revenue (SUM) |
|---|---|---|
| Ramesh Shrestha | 3 | 980.00 |
| Bikash Thapa | 2 | 730.00 |
| Sita Rai | 2 | 500.00 |
COUNT(*) counted the rows in each bucket, SUM(fare_amount) added the fares. This is the core mechanic of every aggregate query.
Sort the aggregated result
ORDER BY revenue DESC runs last — after aggregation. This is why you can ORDER BY an aggregate like SUM(fare_amount), but you cannot WHERE on it — WHERE runs before the groups exist.
| # | driver_name | rides | revenue ↓ |
|---|---|---|---|
| 1 | Ramesh Shrestha | 3 | 980.00 |
| 2 | Bikash Thapa | 2 | 730.00 |
| 3 | Sita Rai | 2 | 500.00 |
"ramesh shrestha" (lowercase)? hash('ramesh shrestha') ≠ hash('Ramesh Shrestha') — it hashes to a completely different bucket, creating a 4th group regardless of where that row sits in the table. That's exactly why a casing mismatch silently splits one driver into two rows in your output.
The full pipeline — one summary
| Phase | What PostgreSQL does | Output |
|---|---|---|
| FROM | Read all rows from disk in storage order | 7 raw rows |
| HashAggregate | Hash each driver_name, drop row into matching bucket |
3 in-memory buckets |
| Aggregate | Run COUNT(*) and SUM() once per bucket |
3 result rows |
| ORDER BY | Sort the 3 result rows by revenue DESC |
Final 3 rows |