When writing SQL queries, we write SELECT first, sprinkle in all the necessary aggregations and joins, mostly without considering what the database executes first. In reality, PostgreSQL runs SELECT almost last, and once that clicks, a bunch of “why is this slow” moments suddenly make sense. Most of those moments boil down to a couple of concepts: early filtering, early aggregation, and early projection. In Postgres, a CTE is one of the cleanest ways to achieve all of that.
This blog is about PostgreSQL query performance that boils down to execution order, early filtering with CTEs, and then how all of that carries over to the code, in the case of this post, Scala. Claims in this post come with benchmarks that you can run yourself; don’t trust the post, test it yourself.
The order you write vs the order it runs
We write SQL naturally in the following order
SELECT … FROM … WHERE … GROUP BY … HAVING … ORDER BY … LIMIT …
Postgres logically evaluates:
1. FROM / JOIN -> Get the rows2. WHERE -> Filter rows3. GROUP BY -> Group rows4. HAVING -> Filter groups5. SELECT -> Construct output columns6. DISTINCT -> Deduplicate7. ORDER BY -> Sort8. LIMIT / OFFSET -> Trim
This one list explains every SQL scoping rule alongside the order of execution:
Why you cannot use aliases in WHERE, it being at step 2, alias from step 5 still does not exist
Aggregates in WHERE, take sum() for example, it still does not exist until after GROUP BY clause, that is why we use HAVING
LIMIT on huge aggregation still being slow, trim happens at the very end, all the grouping work is finalized before it
Important note: this is logical order. Query planner reorders things however it likes, as long as the result is the same. That freedom is exactly what the next part is about.
Early filtering, CTEs and benchmarks
Remember this one rule of query performance: filter out rows as early as possible. Rows that are filtered in a scan never get joined, grouped, sorted, or shipped to your JVM.
CTEs (WITH … AS) are a nice way to make that explicit.
A little bit about Postgres CTEs history:
Pre v12: every CTE was an optimization fence. It got fully materialized, and outer filters could not be pushed inside.
After v12: CTE referenced once, with no side effects gets inlined by default, the planner treats it like a subquery and pushes your filters in. A CTE referenced more than once is still materialized by default.
You can force either behavior with NOT MATERIALIZED or MATERIALIZED
Sounds interesting? Let us test it.
Setup (2 minutes, ~1GB of fake data)
Setup docker with the latest Postgres, latest version at the time of writing this post is 17.x:
docker run --name postgres-perf -e POSTGRES_USER=root -e POSTGRES_PASSWORD=root -e POSTGRES_DB=root -p 5432:5432 -d postgres:18.4
Now you can run psql from the container itself:
docker exec -it postgres-perf psql -U root -d root
Run the following psql against the database to generate ~1GB of fake data:
DROP TABLE IF EXISTS public.orders;DROP TABLE IF EXISTS public.customers; CREATE TABLE public.customers ( "id" BIGINT PRIMARY KEY, "name" TEXT NOT NULL, "country" TEXT NOT NULL);INSERT INTO public.customers ("id", "name", "country")SELECT series, 'customer_' || series, (ARRAY['DE', 'FR', 'US', 'JP', 'BR'])[1 + (random() * 4)::int]FROM generate_series(0, 100000) AS series;CREATE TABLE public.orders ( "id" BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, "customer_id" BIGINT NOT NULL, "amount" NUMERIC(10, 2) NOT NULL, "created_at" TIMESTAMPTZ NOT NULL);INSERT INTO public.orders ("customer_id", "amount", "created_at")SELECT (random() * 100000)::BIGINT, round((random() * 500)::NUMERIC, 2), now() - (random() * INTERVAL '730 days')FROM generate_series(1, 10000000); ALTER TABLE public.orders ADD CONSTRAINT fk_orders_customer FOREIGN KEY ("customer_id") REFERENCES public.customers ("id");CREATE INDEX idx_orders_customer_created ON public.orders ("customer_id", "created_at");
That one index that was created at the end deserves a comment, because indexing is an early filtering delivery mechanism. An index on customer_id, created_at allows Postgres to jump straight to one of the customer's recent rows instead of scanning 10M rows. Another important thing to note, column order inside index matters, equality columns first, range columns second. Flip them, and the index gets far less useful. A WHERE clause without a supporting index still filters early logically, but physically it is a full table scan. You need both.
All of the benchmark results are reported from running the queries with Postgres 17.x, on MacBook Pro M1, 16GB RAM.
All plans run with parallel query disabled so the execution plans stay readable, with parallelism on, expect these queries to run faster. If you want to reproduce these exact plans, disable parallelism first:
SET max_parallel_workers_per_gather = 0SET max_parallel_workers = 0;SET max_parallel_maintenance_workers = 0;
Benchmark 1: Materialization fence
We will run the same query twice, once letting the planner inline the CTE, once fencing it off.
-- inlined, default in Postgres 12+, made explicit hereEXPLAIN (ANALYZE, TIMING)WITH recent AS NOT MATERIALIZED ( SELECT o.* FROM public.orders o WHERE o."created_at" >= now() - INTERVAL '30 days')SELECT r.* FROM recent rWHERE r."customer_id" = 42;QUERY PLAN ------------------------------------------------------------------Index Scan using idx_orders_customer_created on orders o --(cost=0.44..20.52 rows=4 width=30) (actual time=0.313..0.530 rows=10.00 --loops=1)|-- Index Cond: ((customer_id = 42) AND (created_at >= (now() - '30 --days'::interval)))-- Index Searches: 1-- Buffers: shared hit=4 read=9--Planning Time: 0.276 ms--Execution Time: 0.596 ms-- fenced, old pre Postgres 12 behaviorEXPLAIN (ANALYZE, BUFFERS, TIMING)WITH recent AS MATERIALIZED ( SELECT o.* FROM public.orders o WHERE o."created_at" >= now() - INTERVAL '30 days')SELECT r.* FROM recent rWHERE r."customer_id" = 42;-- QUERY PLAN----------------------------------------------------------------CTE Scan on recent r (cost=248531.40..257885.98 rows=4 width=40) (actual --time=66.902..1380.055 rows=10.00 loops=1)-- Filter: (customer_id = 42)-- Rows Removed by Filter: 410569-- Storage: Disk Maximum Storage: 16840kB-- Buffers: shared hit=16166 read=57364, temp written=2105-- CTE recent-- -> Seq Scan on orders o (cost=0.00..248531.40 rows=415759 width=30) --(actual time=2.165..1319.926 rows=410579.00 loops=1)-- Filter: (created_at >= (now() - '30 days'::interval))-- Rows Removed by Filter: 9589421-- Buffers: shared hit=16166 read=57364--Planning Time: 0.070 ms--JIT:-- Functions: 4-- Options: Inlining false, Optimization false, Expressions true, --Deforming true-- Timing: Generation 0.148 ms (Deform 0.058 ms), Inlining 0.000 ms, --Optimization 0.145 ms, Emission 1.707 ms, Total 1.999 ms--Execution Time: 1381.469 ms
Same query with the same data, ~2000x difference, purely because the fence blocked the customer ID filter from reaching the index.
Benchmark 2: Materialization is a friend
Fences are a tool. If a CTE is expensive and referenced multiple times, this benchmark will demonstrate the benefit that materialization gives you:
-- referenced twice, not materialized, computed twiceEXPLAIN (ANALYZE, TIMING)WITH totals AS NOT MATERIALIZED ( SELECT o.customer_id, sum(o.amount) AS total FROM public.orders o GROUP BY o.customer_id)SELECT count(t.*) FROM totals t JOIN totals tb ON t.total < tb.total AND t.customer_id = tb.customer_id + 1;-- QUERY PLAN---------------------------------------------------------------- Aggregate (actual time=89813.883..89813.899 rows=1)-- -> Hash Join (actual time=44888.306..89805.494 rows=49970)-- Hash Cond: ((tb.customer_id + 1) = t.customer_id)-- Join Filter: (t.total < tb.total)-- Rows Removed by Join Filter: 50030-- -> GroupAggregate (actual time=214.027..45005.852 rows=100001)-- Group Key: o.customer_id-- -> Index Scan ... on orders o (actual time=0.113..43270.754 rows=10000000)-- -> Hash (actual time=44671.326..44671.337 rows=100001)-- -> GroupAggregate (actual time=0.058..44561.166 rows=100001)-- Group Key: o_1.customer_id-- -> Index Scan ... on orders o_1 (actual time=0.012..43081.357 rows=10000000)-- Execution Time: 89816.048 ms-- referenced twice, materialized, computed onceEXPLAIN (ANALYZE, TIMING)WITH totals AS MATERIALIZED ( SELECT o.customer_id, sum(o.amount) AS total FROM public.orders o GROUP BY o.customer_id)SELECT count(t.*) FROM totals t JOIN totals tb ON t.total < tb.total AND t.customer_id = tb.customer_id + 1;-- QUERY PLAN---------------------------------------------------------------- Aggregate (actual time=43554.152..43554.158 rows=1)-- CTE totals-- -> GroupAggregate (actual time=126.229..43368.654 rows=100001)-- Group Key: o.customer_id-- -> Index Scan ... on orders o (actual time=0.121..41789.548 rows=10000000)-- -> Hash Join (actual time=43506.342..43552.120 rows=49970)-- Hash Cond: ((tb.customer_id + 1) = t.customer_id)-- Join Filter: (t.total < tb.total)-- Rows Removed by Join Filter: 50030-- -> CTE Scan on totals tb (actual time=126.234..134.753 rows=100001)-- -> Hash (actual time=43379.620..43379.621 rows=100001)-- -> CTE Scan on totals t (actual time=0.871..43317.044 rows=100001)-- Execution Time: 43557.286 ms
In non materialized run you will typically see the 10M row aggregation appear twice in the plan, in materialized run it runs once and the second reference is cheap CTE scan over ~100k rows.
Rules of thumb from two previous benchmarks:
If a CTE is used once, leave it alone, default inlining lets filters combine
If a CTE is used many times it is MATERIALIZED by default, keep explicit MATERIALIZED for a pricey single use CTE that you do not want recomputed
Never guess, always profile, it is free and it does not lie.
Benchmark 3: Filter first with CTE vs filter alongside aggregation
Two ways to get the 30 day totals per customer:
Stage work with CTE: filter first, then aggregate what is left
Skip the staging process and push the condition inside aggregation itself, every row gets read and grouped
-- Early CTE filter, referenced only onceEXPLAIN (ANALYZE, TIMING)WITH recent AS NOT MATERIALIZED ( SELECT o."customer_id", o."amount" FROM public.orders o WHERE o."created_at" >= now() - INTERVAL '30 days')SELECT r."customer_id", sum(r."amount") FROM recent rGROUP BY r."customer_id";-- QUERY PLAN---------------------------------------------------------------- HashAggregate (cost=275155.97..280431.83 rows=97379 width=40) (actual -- time=1798.378..1911.304 rows=98317.00 loops=1)-- Group Key: o.customer_id-- Planned Partitions: 4 Batches: 5 Memory Usage: 8241kB Disk Usage: -- 11616kB-- Buffers: shared hit=16147 read=57383, temp read=1309 written=2424-- -> Seq Scan on orders o (cost=0.00..248531.40 rows=415603 width=14) -- (actual time=7.015..1630.068 rows=410404.00 loops=1)-- Filter: (created_at >= (now() - '30 days'::interval))-- Rows Removed by Filter: 9589596-- Buffers: shared hit=16147 read=57383-- Planning Time: 0.430 ms-- Execution Time: 1918.483 ms-- condition lives alongside aggregate, scans everythingEXPLAIN (ANALYZE, TIMING)SELECT o."customer_id", sum(o."amount") FILTER (WHERE o."created_at" >= now() - INTERVAL '30 days')FROM public.orders oGROUP BY o."customer_id"HAVING sum(o."amount") FILTER (WHERE o."created_at" >= now() - INTERVAL '30 days') IS NOT NULL-- QUERY PLAN---------------------------------------------------------------- GroupAggregate (cost=0.43..724378.16 rows=98225 width=40) (actual time=45.492..44438.285 rows=98317.00 loops=1)-- Group Key: customer_id-- Filter: (sum(amount) FILTER (WHERE (created_at >= (now() - '30 days'::interval))) IS NOT NULL)-- Rows Removed by Filter: 1684-- Buffers: shared hit=2205027 read=7833152-- -> Index Scan using idx_orders_customer_created on orders o (cost=0.43..598143.18 rows=10000080 width=22) (actual time=0.192..42454.429 -- rows=10000000.00 loops=1)-- Index Searches: 1-- Buffers: shared hit=2205027 read=7833152-- Planning Time: 0.133 ms-- Execution Time: 44448.656 ms
Two things worth noting from the previous test:
CTE in the first query is referenced only once, so Postgres 12+ inlines it.
The second query is not just a made-up example. It shows up in real code whenever someone computes several conditional sums in one pass and fails to notice that no shared filter means scanning everything.
Benchmark 4: Aggregate first with CTE vs join first then aggregate
Early filtering has its sibling, early aggregation. If you are going to collapse 10M order rows into 100k customer totals, do it before JOIN, not after. Joining first means handling 10M rows, aggregating first means handling only 100k.
-- join first, then aggregate-- join sits below the aggregate and processes all 10M roesEXPLAIN (ANALYZE, TIMING)SELECT c."name", sum(o."amount") AS totalFROM public.orders oJOIN public.customers c ON c."id" = o."customer_id"GROUP BY c."id", c."name"ORDER BY total DESCLIMIT 10;-- QUERY PLAN---------------------------------------------------------------- Limit (cost=780135.67..780135.70 rows=10 width=54) (actual time=52038.923..52038.930 rows=10.00 loops=1)-- Buffers: shared hit=2204772 read=7834420 written=1-- -> Sort (cost=780135.67..780385.67 rows=100001 width=54) (actual time=51984.158..51984.159 rows=10.00 loops=1)-- Sort Key: (sum(o.amount)) DESC-- Sort Method: top-N heapsort Memory: 26kB-- Buffers: shared hit=2204772 read=7834420 written=1-- -> GroupAggregate (cost=7.21..777974.69 rows=100001 width=54) (actual time=1.726..51935.407 rows=100001.00 loops=1)-- Group Key: c.id-- Buffers: shared hit=2204769 read=7834420 written=1-- -> Merge Join (cost=7.21..726724.27 rows=10000080 width=28) (actual time=0.074..50530.788 rows=10000000.00 loops=1)-- Merge Cond: (o.customer_id = c.id)-- Buffers: shared hit=2204769 read=7834420 written=1-- -> Index Scan using idx_orders_customer_created on -- orders o (cost=0.43..598143.18 rows=10000080 width=14) (actual time=0.029..48764.432 rows=10000000.00 loops=1)|-- Index Searches: 1-- Buffers: shared hit=2204767 read=7833412 written=1 -- -> Index Scan using customers_pkey on customers c -- (cost=0.29..3342.31 rows=100001 width=22) (actual time=0.030..37.442 rows=100001.00 loops=1)-- Index Searches: 1-- Buffers: shared hit=2 read=1008-- Planning Time: 5.058 ms-- Execution Time: 52042.793 ms-- aggregate first in CTE, then join-- join sits above the aggregate and only sees 100k pre-collapsed rowsEXPLAIN (ANALYZE, TIMING)WITH totals AS NOT MATERIALIZED ( SELECT o."customer_id", sum(o."amount") AS total FROM public.orders o GROUP BY o."customer_id")SELECT c."name", t.totalFROM totals tJOIN public.customers c ON c."id" = t."customer_id"ORDER BY t.total DESCLIMIT 10;-- QUERY PLAN---------------------------------------------------------------- Limit (cost=657324.33..657324.36 rows=10 width=46) (actual time=44288.081..44288.088 rows=10.00 loops=1)-- Buffers: shared hit=2204819 read=7834373-- -> Sort (cost=657324.33..657571.13 rows=98719 width=46) (actual time=44242.794..44242.795 rows=10.00 loops=1)-- Sort Key: (sum(o.amount)) DESC-- Sort Method: top-N heapsort Memory: 26kB-- Buffers: shared hit=2204819 read=7834373-- -> Merge Join (cost=0.73..655191.05 rows=98719 width=46) (actual time=1.644..44199.715 rows=100001.00 loops=1)-- Merge Cond: (o.customer_id = c.id)-- Buffers: shared hit=2204816 read=7834373-- -> GroupAggregate (cost=0.43..649377.56 rows=98719 width=40) (actual time=1.569..44130.428 rows=100001.00 loops=1)-- Group Key: o.customer_id-- Buffers: shared hit=2204816 read=7833363-- -> Index Scan using idx_orders_customer_created on -- orders o (cost=0.43..598143.18 rows=10000080 width=14) (actual time=0.100..42614.593 rows=10000000.00 loops=1)|-- Index Searches: 1-- Buffers: shared hit=2204816 read=7833363-- -> Index Scan using customers_pkey on customers c (cost=0.29..3342.31 rows=100001 width=22) (actual time=0.066..27.299 rows=100001.00 loops=1)-- Index Searches: 1-- Buffers: shared read=1010-- Planning Time: 1.718 ms -- Execution Time: 44290.105 ms
In the first query, mege join sits below the aggregate and processes all 10M rows. In the second query, join sits above the aggregate and only sees 100k pre-collapsed rows. Since these are simple test tables, imagine real tables with 10s of columns, more columns, bigger second queries win, because the first query drags all those columns through JOIN and the GROUP BY for nothing.
Scala with Doobie
Scala on the JVM talks to Postgres over JDBC, and Doobie wraps that in something pleasant. Postgres performance rules carry over directly, with one addition: the JVM is the most expensive place to filter. Every row you pull over the network gets deserialized by the JDBC driver and becomes GC pressure before .filter throws it away.
Good version, CTE, early filters, parameters, all filtering inside Postgres:
import doobie._import doobie.implicits._import cats.effect.IOfinal case class BigSpender(name: String, total: BigDecimal) derives Readdef bigSpenders(minTotal: BigDecimal, days: Int): Query0[BigSpender] = sql""" WITH recent_orders AS ( SELECT o."customer_id", o."amount" FROM public.orders o WHERE o."created_at" >= now() - make_interval(days => $days) ), totals AS ( SELECT ro."customer_id", sum(ro."amount") AS total FROM recent_orders ro GROUP BY ro."customer_id" HAVING sum(ro."amount") > $minTotal ) SELECT c."name", t."total" FROM totals t JOIN public.customers c ON c."id" = t."customer_id" ORDER BY t."total" DESC """.query[BigSpender]def run(xa: Transactor[IO]): IO[List[BigSpender]] = bigSpenders(BigDecimal(1000), 30).to[List].transact(xa)
Why is this a good shape:
$days and $minTotal become JDBC PreparedStatement parameters, making it safe from SQL injection and adding plan caching for free
Heavy lifting happens in Postgres, Scala receives final result and maps it into case classes
CTE is single use, so Postgres 12+ inlines it and pushes filters freely, same situation as in our first benchmark.
Antipattern for contrast:
sql""" SELECT o."customer_id", o."amount", o."created_at" FROM orders o """ .query[(Long, BigDecimal, java.time.Instant)] .to(List) .map(_.filter(_._3.isAfter(cutoff)))
Expect the JVM side filtering version to be noticeably slower, and to spike up heap usage quite a bit compared to Postgres heavy lifting version.
Cleanup
Remove Docker container
docker container rm postgres-perf
Clean up Docker image
docker rmi postgres:18.4
Takeaways
Logical order first - SQL runs logically as FROM -> WHERE -> GROUP BY -> HAVING -> SELECT -> ORDER BY -> LIMIT. SELECT executes after filtering, that is why aliases work in ORDER BY but not in WHERE.
Filter early and use indexes - filter early, and give the filter an index, WHERE without index is physically a full table scan (adding the index on a large live table is a challenge on its own).
Aggregate before you join - aggregate early, collapsing rows before join means joins handle significantly fewer rows.
CTEs are inlined by default - in Postgres 12+, single use CTEs are inlined. MATERIALIZED is a deliberate fence: great for reuse, terrible when it blocks an index.
Let Postgres do the work - use parameterized SQL queries in Scala, leave heavy lifting to Postgres, return necessary results. Never fetch and then filter
Never guess, always profile, it is free and it does not lie
Sources
PostgreSQL: Documentation: 18: 7.8. WITH Queries (Common Table Expressions)
PostgreSQL: Documentation: 18: 11.3. Multicolumn Indexes
PostgreSQL: Documentation: 18: 14.1. Using EXPLAIN
PostgreSQL: Documentation: 18: SELECT
Parameterized Queries · doobie
Thanks for a great article! One question:
What is the reason for using ios-deploy to install the app, instead of letting appium do it?
/Alex
Thanks Alex,
We used ios-deploy because we were unable to install app via Appium on the real device (on emulator, it works out of the box when you run your tests). We needed cli tool for app installation on device since we planned to run this in CI environment.
Hope it helps,
Bakir