SQL Execution Flow

From a string to rows

When an application sends SELECT ... to PostgreSQL, the text goes through a pipeline of stages inside the server, each turning one representation into the next. Knowing the stages explains where time goes (parsing vs. planning vs. execution), why prepared statements help and sometimes hurt, and why a query's clauses resolve names in the order they do.

graph LR conn["Connection<br/>(one backend process)"] --> parse["Parser<br/>text → parse tree"] parse --> analyze["Analyzer<br/>resolve names, types → query tree"] analyze --> rewrite["Rewriter<br/>expand views, apply rules / RLS"] rewrite --> plan["Planner<br/>choose cheapest plan → plan tree"] plan --> exec["Executor<br/>run plan tree, pull rows"] exec --> out["Rows sent to client"]

1. Connection: a process per client

PostgreSQL forks one backend process per client connection (the postmaster accepts connections and forks). Each backend has its own memory for sorts, hashes, caches of catalog data and plans — typically several MB to tens of MB each. That is why PostgreSQL deployments put a connection pooler (PgBouncer, or an application-side pool) in front: thousands of mostly idle client connections share tens of backends. See Connection Pooling. MySQL instead uses a thread per connection, with similar pooling motivations.

2. Parser: text to a parse tree

The parser checks syntax only, turning the text into a tree of nodes: a SELECT with a target list, a FROM list, a WHERE expression. It does not know whether tables exist. A syntax error is reported here.

3. Analyzer: names, types, and meaning

The analyzer resolves every name against the system catalogs: which table orders is (taking search_path into account), which column customer_id is and its type, which function lower(text) resolves to, which implicit casts apply. Output: a query tree. "Column does not exist" and type errors come from here.

The logical order of clauses

Name resolution follows the logical order the SQL standard defines for evaluating a SELECT:

graph TD A["1. FROM, JOIN<br/>build the input rows"] --> B["2. WHERE<br/>filter rows"] B --> C["3. GROUP BY<br/>form groups"] C --> D["4. HAVING<br/>filter groups"] D --> W["5. Window functions"] W --> E["6. SELECT list<br/>compute output columns, aliases"] E --> F["7. DISTINCT"] F --> G["8. ORDER BY"] G --> H["9. LIMIT / OFFSET"]

A name can only refer to something defined by an earlier step:

-- Error: alias `total` is defined in step 6; WHERE is step 2
SELECT price * qty AS total FROM orders WHERE total > 100;

-- Works: repeat the expression (or use a subquery / CTE)
SELECT price * qty AS total FROM orders WHERE price * qty > 100;

-- Works: ORDER BY (step 8) comes after SELECT (step 6)
SELECT price * qty AS total FROM orders ORDER BY total DESC;

-- Aggregates exist only from step 3 on: they can't appear in WHERE
SELECT customer_id, sum(amount) FROM orders
GROUP BY customer_id
HAVING sum(amount) > 1000;

-- Window functions are computed in step 5: filter on them via a subquery
SELECT * FROM (
  SELECT id, row_number() OVER (PARTITION BY customer_id ORDER BY created_at DESC) AS rn
  FROM orders
) t WHERE rn = 1;

This order defines meaning, not physical execution. The planner may evaluate a WHERE condition before a join, read rows already sorted from an index instead of sorting at step 8, or stop early because of LIMIT — as long as the result equals what the logical order defines.

WHERE vs. HAVING: a condition that doesn't involve an aggregate belongs in WHERE, where it can use indexes and reduces the rows entering grouping. (PostgreSQL moves such conditions from HAVING to WHERE itself in simple cases, but writing them in WHERE makes the intent and the plan obvious.)

4. Rewriter: views and rules

The rewriter replaces references to views with their defining queries (a view is a stored query, expanded inline, then optimized together with the outer query), applies rewrite rules, and adds row-level security policies as extra conditions. That is why a condition on a view can be pushed down into the view's tables and use their indexes.

5. Planner: choosing how

The planner enumerates ways to compute the query tree — access paths for each table (seq scan, each usable index, bitmap combinations), join algorithms and join orders, where to sort or hash, whether to aggregate by hashing or by sorting, whether to use parallel workers — estimates each one's cost from statistics, and keeps the cheapest. It also simplifies: constant folding, flattening subqueries into joins, converting EXISTS into semi joins, removing unneeded joins. The details are in Query Optimization.

Planning Time in EXPLAIN ANALYZE measures this stage. For a simple indexed lookup it was 0.07 ms in our measurements — comparable to the 0.085 ms execution. For short OLTP queries, planning can be a large share of total time, which is what prepared statements address.

6. Executor: the iterator (Volcano) model

The plan is a tree of nodes; each node implements the same interface:

Init()      prepare (open indexes, allocate hash tables)
Next()      return the next row, or "done"
End()       release resources

The client-facing top node calls Next() on its child, which calls Next() on its children, and so on down to the scans: rows are pulled up the tree one at a time.

Limit (10)
  └── Sort (by created_at)
        └── Hash Join
              ├── Seq Scan on orders
              └── Hash
                    └── Seq Scan on customers

This Volcano (Graefe, 1994) design makes every operator composable, and naturally supports pipelining: a Limit stops calling Next() after 10 rows, so a nested loop or index scan beneath it stops early. Some nodes are blocking — Sort, Hash (the build side), aggregation by sort — and must consume their whole input before returning their first row, which is why the same LIMIT 10 can be instant over an index scan and slow over a sort.

The cost of the model is one virtual function call per row per node. Analytical engines (DuckDB, ClickHouse, Velox) instead process vectors of thousands of values per call (vectorized execution) or compile the plan into machine code (HyPer, Umbra; PostgreSQL's optional JIT compiles expressions), gaining 10x or more on scans and aggregations over many rows.

Modifications (INSERT, UPDATE, DELETE) run through the same executor: a ModifyTable node at the top consumes rows from the plan below (which finds the rows to change) and writes new tuple versions, index entries, and WAL records.

Simple vs. extended protocol, and prepared statements

Simple query protocol Extended protocol / prepared statements
Messages One Query message with the full SQL text Parse (with $1 placeholders) → Bind (values) → Execute
Parsing / planning Every time Parse once; plan can be cached and reused
Parameters Inlined in the text (SQL injection risk if built by string concatenation) Sent separately — injection-proof by construction

Plan caching: a prepared statement runs its first five executions with custom plans (planned with the actual parameter values), then switches to a generic plan (planned once, without values) if that plan's estimated cost isn't significantly higher. Generic plans save planning time but can be wrong for skewed data — the plan that is right for status = 'paid' (49% of rows: seq scan) is wrong for status = 'cancelled' (1%: index scan). plan_cache_mode controls the choice.

Transaction-pooling connection poolers historically broke session-level prepared statements (the next transaction may run on a different backend); PgBouncer 1.21+ tracks them.

Observing each stage

Tool Shows
EXPLAIN (ANALYZE, BUFFERS) Plan, real rows and time per node, planning time
pg_stat_statements Per normalized query: calls, total/mean time, rows, buffers
auto_explain Logs plans of queries slower than a threshold, automatically
log_min_duration_statement Logs slow statements
pg_stat_activity What each backend is running now, and what it waits on

Common pitfall

Relying on clause order quirks of one database. PostgreSQL and MySQL allow a SELECT alias in GROUP BY as an extension, although by the logical order GROUP BY (step 3) runs before aliases exist (step 6); the same query fails on databases that follow the standard strictly. More subtly, MySQL historically allowed selecting non-aggregated columns not listed in GROUP BY and returned values from an arbitrary row of each group — a silently non-deterministic result (rejected by default since ONLY_FULL_GROUP_BY became default in 5.7). Write queries whose meaning follows from the standard order, and they will mean the same thing everywhere.