DB Internals AtlasPostgreSQL 18.6 Home About

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

  1. Parser (gram.y, scan.l): pure syntax, no catalog access. Produces a raw parse tree. Errors: ERROR: syntax error at or near "selec"
  2. Analyzer (parse analysis): resolves names to OIDs, types and operators, expands *, produces a Query tree. Errors: ERROR: relation "customer" does not existERROR: column "nam" does not exist HINT: Perhaps you meant to reference the column "customers.name".
  3. Rewriter: applies rules from pg_rewrite. Every view is a _RETURN rule, so the view reference becomes a subquery. Row-level security policies are also added here.
  4. Planner/optimizer: pulls up subqueries, generates paths (scan methods per table, join orders and methods), costs them with pg_statistic data and the cost parameters, and turns the cheapest path into a plan. With geqo_threshold (12) or more FROM items it switches to the genetic optimizer; join_collapse_limit (explicit JOINs) and from_collapse_limit (pulled-up subqueries), both 8, cap how many items are merged into one list whose join order is searched.
  5. 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: 1802000
  • cost=startup..total in arbitrary units (one sequential page read = 1.0); rows is the estimate. Compare it with actual rows: a 10× or worse misestimate is the usual root cause of a bad plan.
  • actual time and rows are per loop; multiply by loops. Since PostgreSQL 18 rows are printed with two decimals, so averages such as rows=0.50 are no longer rounded to 0.
  • PostgreSQL 18 includes Buffers: automatically in EXPLAIN ANALYZE and reports Index Searches for index scans.
  • Use EXPLAIN (ANALYZE, BUFFERS, SETTINGS, WAL) for tickets; wrap data-changing statements in BEGIN … ROLLBACK because 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) and exec_parse_message() / exec_bind_message() / exec_execute_message() (extended protocol), all in postgres.c.
  • Parser: gram.y, scan.l, parser.c (raw_parser()), analyze.c (parse_analyze_fixedparams() → transformTopLevelStmt() → transformStmt()).
  • Rewriter: rewriteHandler.c: QueryRewrite() runs RewriteQuery() (rules on INSERT/UPDATE/DELETE), then fireRIRrules(), which expands views and calls get_row_security_policies().
  • Optimizer: planner.c, planmain.c, allpaths.c, createplan.c, setrefs.c.
  • Executor: pquery.c (portals), execMain.c, execProcnode.c, plus one node*.c file per plan node type.

Call flow of one simple-protocol SELECT

  1. pg_parse_query() → raw_parser(): a list of RawStmt.
  2. pg_analyze_and_rewrite_fixedparams(): parse analysis gives one Query; pg_rewrite_query() → QueryRewrite() returns a list of them.
  3. pg_plan_queries() → pg_plan_query() → planner() → standard_planner() → subquery_planner() → grouping_planner() → query_planner() → make_one_rel() (paths); then create_plan() and set_plan_references() produce the PlannedStmt.
  4. PortalStart() → ExecutorStart() → InitPlan() → ExecInitNode() builds the PlanState tree; PortalRun() → PortalRunSelect() → ExecutorRun() → ExecutePlan() calls ExecProcNode() 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 of RangeTblEntry.
  • PlannerInfo, RelOptInfo, Path (pathnodes.h): planner working state; PlannedStmt / Plan (plannodes.h) are the read-only result.
  • QueryDesc, EState and PlanState (execnodes.h): run-time state. Each PlanState has an ExecProcNode function pointer; the first call goes through ExecProcNodeFirst(), 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() asks choose_custom_plan(), which builds custom plans while num_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 (with debug_pretty_print) write the node trees to the server log at LOG level; log_parser_stats, log_planner_stats, log_executor_stats log 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