Why Your SQL Query Is Slow: Reading an Execution Plan
Learn to read EXPLAIN output, spot the difference between a scan and an index, and fix the handful of causes behind most slow SQL queries.
Short answer: Use a CTE to make a query readable, a subquery for a small one-off lookup, and a temp table when a big intermediate result is used several times or needs an index. None of them is always faster, so read the query plan before assuming.
Every analyst hits this moment: the query has grown to sixty lines, three levels of brackets, and nobody, including you, can say what the middle part does. You have three tools to fix it, and they look similar enough to cause arguments.
During an early stage of my career, I happened to write a very long one-block style SQL query with plenty of subqueries. This has been the worst idea to do, when 6 months later I had to go through it again - as of course little was documented.
Here is how I think about them, what the documentation and the community say about performance, and a rule of thumb that has served me well.
A subquery is a query inside another query. It lives for the length of that one statement.
SELECT customer_id, revenue
FROM (
SELECT customer_id, SUM(amount) AS revenue
FROM orders
GROUP BY customer_id
) t
WHERE revenue > 1000;
A CTE (common table expression) gives that inner query a name at the top, using WITH. It also lives for one statement.
WITH revenue_by_customer AS (
SELECT customer_id, SUM(amount) AS revenue
FROM orders
GROUP BY customer_id
)
SELECT customer_id, revenue
FROM revenue_by_customer
WHERE revenue > 1000;
A temp table is a real, temporary table stored for your session. You create it once and can use it many times.
CREATE TEMP TABLE revenue_by_customer AS
SELECT customer_id, SUM(amount) AS revenue
FROM orders
GROUP BY customer_id;
The first two are about how you write the query. The third is about storing an intermediate result.
It depends, and anyone who tells you otherwise is selling something. The sources I checked agree on the general shape:
One detail that surprises people: whether a CTE is computed once or re-computed each time you reference it depends on the database. In PostgreSQL 12 and later, a simple CTE referenced once is usually inlined like a subquery, and you can force either behaviour with MATERIALIZED or NOT MATERIALIZED. In SQL Server, a CTE is not stored; each reference is expanded into the query. So a CTE used three times may do the work three times.
The honest advice is to look at the query plan (EXPLAIN in most databases) before deciding.
Here is the rule of thumb I use.
| Situation | Use |
|---|---|
| The query is hard to read and each step is used once | CTE |
| A tiny lookup, such as “customers above the average” | Subquery |
| The same big intermediate result is used several times | Temp table |
| You need an index on the intermediate result | Temp table |
| You need to inspect the middle step while debugging | Temp table or a CTE you can run on its own |
| The database is a read-only warehouse and you can’t create tables | CTE |
Give each CTE a name that says what one row is, and let each step do one thing.
WITH paid_orders AS (
SELECT * FROM orders WHERE status = 'paid'
),
customer_revenue AS (
SELECT customer_id, SUM(amount) AS revenue
FROM paid_orders
GROUP BY customer_id
),
ranked AS (
SELECT *, RANK() OVER (ORDER BY revenue DESC) AS rnk
FROM customer_revenue
)
SELECT * FROM ranked WHERE rnk <= 10;
You can read that top to bottom like a recipe. Someone else can too.
My take: default to CTEs, and reach for a temp table only when the plan tells you to. That is an opinion, not a law. If you work on a huge table where every step is expensive, the balance shifts toward temp tables with indexes.
If you want to go further with CTEs, a good next step is SQL window functions, which pair with them constantly.
Sources: LearnSQL: CTE vs Temp Table, Maven Analytics: Subqueries vs Temporary Tables vs CTEs, Microsoft Q&A: CTE vs temp table performance. The PostgreSQL 12 inlining behaviour is documented in the PostgreSQL manual (WITH queries). Table and column names are illustrative.
Part of: SQL & Local Analytics