Query processing
Life of a query
Ordinary optimizable SQL statements such as SELECT, INSERT, UPDATE and DELETE are parsed, analyzed, rewritten, planned and executed inside the backend. Utility commands share the parsing/analysis front end but do not go through the regular rewrite-and-planner path in the same way.
In plain words
For an ordinary optimizable statement such as SELECT, your backend first runs the parser to check SQL syntax, then parse analysis resolves tables, columns, types and operators against the catalogs. The rewriter then applies query-rewrite rules, including view expansion and row-security additions. Utility commands such as many DDL statements take a different path and are not planned like ordinary queries.
The planner (also called the optimizer) is the clever part. There are usually many ways to get the same answer: read the whole table or use an index, join A to B or B to A. It estimates the cost of each option using table statistics and picks the cheapest one. The result is a plan: a tree of small workers.
The executor runs that tree. The top node asks its children for "the next row", they ask their children, and rows bubble up one by one until they reach you. EXPLAIN shows the plan; EXPLAIN ANALYZE runs it and shows what really happened.
For DBAs and operators
PostgreSQL 18 source trail: postgres.c shows parse analysis, query rewriting and planning; CMD_UTILITY commands are explicitly excluded from normal rewriting/planning.
The five stages
- Parser (
gram.y,scan.l): pure syntax, no catalog access. Produces a raw parse tree. Errors: ERROR: syntax error at or near "selec" - Analyzer (parse analysis): resolves names to OIDs, types and operators, expands
*, produces aQuerytree. Errors: ERROR: relation "customer" does not existERROR: column "nam" does not exist HINT: Perhaps you meant to reference the column "customers.name". - Rewriter: applies rules from
pg_rewrite. Every view is a_RETURNrule, so the view reference becomes a subquery. Row-level security policies are also added here. - Planner/optimizer: pulls up subqueries, generates paths (scan methods per table, join orders and methods), costs them with
pg_statisticdata and the cost parameters, and turns the cheapest path into a plan. Withgeqo_threshold(12) or more FROM items it switches to the genetic optimizer;join_collapse_limit(explicit JOINs) andfrom_collapse_limit(pulled-up subqueries), both 8, cap how many items are merged into one list whose join order is searched. - Executor: a demand-driven "Volcano" iterator model. Each node implements init / next / end; calling next on the root pulls rows through the tree. Blocking nodes (Sort, Hash, HashAggregate) must consume their whole input before returning the first row, which is why they have a high startup cost.
Plan cache and prepared statements
With PREPARE / the extended protocol (most drivers), parse and rewrite happen once. The first five executions get a custom plan built for the actual parameter values. After that the backend also builds a generic plan (parameters as $1) and keeps using it if its estimated cost is lower than the average custom plan cost (which includes an estimate of the planning overhead). plan_cache_mode = auto | force_custom_plan | force_generic_plan overrides that. A generic plan that ignores skewed values is a classic "fast in psql, slow from the app" ticket.
PREPARE q(int) AS SELECT * FROM orders WHERE customer_id = $1;
EXECUTE q(42); -- x6 or more
SELECT name, generic_plans, custom_plans FROM pg_prepared_statements;
EXPLAIN (GENERIC_PLAN) SELECT * FROM orders WHERE customer_id = $1; -- PG16+Reading EXPLAIN ANALYZE (PostgreSQL 18)
Hash Join (cost=2188.88..40461.23 rows=50070 width=15) (actual time=20.943..247.074 rows=50000.00 loops=1)
Hash Cond: (orders.customer_id = c.id)
Buffers: shared read=13367
-> Seq Scan on orders (cost=0.00..37739.00 rows=203173 width=10) ...
Filter: (amount > '90'::numeric)
Rows Removed by Filter: 1802000cost=startup..totalin arbitrary units (one sequential page read = 1.0);rowsis the estimate. Compare it withactual rows: a 10× or worse misestimate is the usual root cause of a bad plan.actual timeandrowsare per loop; multiply byloops. Since PostgreSQL 18 rows are printed with two decimals, so averages such asrows=0.50are no longer rounded to 0.- PostgreSQL 18 includes
Buffers:automatically inEXPLAIN ANALYZEand reportsIndex Searchesfor index scans. - Use
EXPLAIN (ANALYZE, BUFFERS, SETTINGS, WAL)for tickets; wrap data-changing statements inBEGIN … ROLLBACKbecause ANALYZE really executes them.
To catch slow plans in production use log_min_duration_statement and the auto_explain module (auto_explain.log_min_duration, auto_explain.log_analyze).
For developers: inside the source
Where it lives
- Traffic cop:
exec_simple_query()(Query message) andexec_parse_message()/exec_bind_message()/exec_execute_message()(extended protocol), all inpostgres.c. - Parser:
gram.y,scan.l,parser.c(raw_parser()),analyze.c(parse_analyze_fixedparams()→transformTopLevelStmt()→transformStmt()). - Rewriter:
rewriteHandler.c:QueryRewrite()runsRewriteQuery()(rules on INSERT/UPDATE/DELETE), thenfireRIRrules(), which expands views and callsget_row_security_policies(). - Optimizer:
planner.c,planmain.c,allpaths.c,createplan.c,setrefs.c. - Executor:
pquery.c(portals),execMain.c,execProcnode.c, plus onenode*.cfile per plan node type.
Call flow of one simple-protocol SELECT
pg_parse_query()→raw_parser(): a list ofRawStmt.pg_analyze_and_rewrite_fixedparams(): parse analysis gives oneQuery;pg_rewrite_query()→QueryRewrite()returns a list of them.pg_plan_queries()→pg_plan_query()→planner()→standard_planner()→subquery_planner()→grouping_planner()→query_planner()→make_one_rel()(paths); thencreate_plan()andset_plan_references()produce thePlannedStmt.PortalStart()→ExecutorStart()→InitPlan()→ExecInitNode()builds thePlanStatetree;PortalRun()→PortalRunSelect()→ExecutorRun()→ExecutePlan()callsExecProcNode()on the root until it returns an empty slot;ExecutorFinish()/ExecutorEnd()clean up.
Key data structures
Query(parsenodes.h): the analyzed tree, with a range table ofRangeTblEntry.PlannerInfo,RelOptInfo,Path(pathnodes.h): planner working state;PlannedStmt/Plan(plannodes.h) are the read-only result.QueryDesc,EStateandPlanState(execnodes.h): run-time state. EachPlanStatehas anExecProcNodefunction pointer; the first call goes throughExecProcNodeFirst(), which checks stack depth and then installs the node's real function (or the instrumentation wrapper used by EXPLAIN ANALYZE).CachedPlanSource/CachedPlan(plancache.c):GetCachedPlan()askschoose_custom_plan(), which builds custom plans whilenum_custom_plans < 5, then prefers the generic plan if its cost is below the average custom cost (planning cost included).
Extension hooks
post_parse_analyze_hook, planner_hook, create_upper_paths_hook, ExecutorStart_hook / ExecutorRun_hook / ExecutorFinish_hook / ExecutorEnd_hook, and ProcessUtility_hook for utility commands. pg_stat_statements.c and auto_explain.c are the reference users.
Observe it
debug_print_parse,debug_print_rewritten,debug_print_plan(withdebug_pretty_print) write the node trees to the server log at LOG level;log_parser_stats,log_planner_stats,log_executor_statslog per-stage resource usage.- READMEs:
parser/README,optimizer/README,executor/README. - Regression tests:
src/test/regress/sql/explain.sql,plancache.sql,rules.sql.
Parameters that shape this
plan_cache_mode from_collapse_limit join_collapse_limit geqo_threshold default_statistics_target log_min_duration_statement
Related topics
Planner cost model Scan methods Join methods Parallel query JIT compilation Client backend Wire protocol ANALYZE and statistics