You type SELECT name FROM users WHERE id = 42 and half a millisecond later, you have a row. That half-millisecond contains more engineering ingenuity than most programs run in an entire request. PostgreSQL — and most relational databases — route your query through six internal stages, each with its own data structures, algorithms, and failure modes.
Understanding these stages is not just academic trivia. It is the difference between an application that handles 10,000 queries per second and one that struggles at 100. It is why adding a single index can drop a query from 4 seconds to 0.2 ms. It is why WHERE LOWER(email) = $1 is a silent performance disaster.
When your application sends a query, here is the full chain of custody:
Watch your query flow through each stage. Press Step to advance one stage, Play to auto-run, Reset to start over.
The SQL query arrives at the backend process as a plain text string — a sequence of bytes over a TCP socket. Before PostgreSQL can do anything useful, it must parse that string into a structured data structure it can reason about. This is exactly the same job that a C compiler or a JavaScript engine does when parsing source code.
The lexer (also called a scanner or tokenizer) reads the query character by character and groups them into tokens — the atomic units of SQL syntax.
The lexer is defined in src/backend/parser/scan.l — a flex file with hundreds of regex rules. It strips whitespace, handles quoted identifiers, recognizes string literals, and normalizes keywords to uppercase internally.
The parser takes the token stream and applies grammar rules to build an Abstract Syntax Tree (AST). In PostgreSQL, this AST is called a parse tree and is made up of C structs. The grammar is defined in gram.y — a 17,000-line bison file.
The internal C function pg_parse_query() takes the query string and returns a List* of parse trees (one per SQL statement in a batch). Each node in the tree is a tagged C struct — the tag tells downstream code what kind of node it is.
If the parser encounters a token that doesn't fit the grammar rules, it throws a syntax error immediately — before any planning or execution happens. These are the errors you see with the telltale "at or near" phrasing:
The parser only checks syntax — grammar rules. It does not check whether the table users actually exists, whether the column name is in that table, or whether the data types are compatible. Those are semantic checks done during the planning phase.
SELECT * FROM nonexistent_table WHERE id = 42 passes the parser successfully. The "relation does not exist" error only appears later when the planner tries to look up table metadata.
The rewriter takes the parse tree from the parser and potentially transforms it. For most simple queries, the rewriter passes the AST straight through untouched. But it plays a critical role for views, row-level security, and rule-based rewrites.
When you define a view in PostgreSQL, you are storing a query. When you query that view, the rewriter replaces the view reference in your parse tree with the view's underlying query definition. This is transparent to the planner.
The planner then sees the fully-expanded query. Because PostgreSQL's optimizer is smart, it will often push the WHERE id = 42 predicate down into the subquery and use an index on the users table directly — a technique called predicate pushdown.
Row-Level Security is one of PostgreSQL's most powerful — and most misunderstood — features. When RLS is enabled on a table, the rewriter automatically injects the applicable security policies into the query as additional WHERE clauses or WITH CHECK constraints.
PostgreSQL has an older rule system (CREATE RULE) that also operates at this stage. Rules can replace or augment queries with arbitrary SQL. They predate triggers and are generally considered legacy — views are implemented using rules internally, but for application code, triggers are almost always preferred over explicit rules.
parse_analyze()) which resolves table and column names against the system catalogs, assigns OIDs to relation references, and checks type compatibility. This is where "column does not exist" errors are thrown.
The planner is where PostgreSQL earns its reputation. Given a rewritten query, the planner's job is to enumerate all possible ways to execute it and select the best one. For a query joining 3 tables, there might be dozens of candidate plans. For a query joining 8 tables, there could be thousands.
The planner doesn't look at your actual data to make decisions — that would be too slow. Instead it uses statistics collected by the ANALYZE command and stored in the pg_statistic system catalog (exposed via the friendlier pg_stats view).
Number of distinct values in the column. If positive, it's the actual count. If negative (e.g. -0.95), it's the fraction of rows with distinct values. Critical for estimating cardinality after a GROUP BY.
Statistical correlation between physical row order and logical column order. 1.0 = perfect correlation (sorted). 0 = random. High correlation means an index scan will be very efficient (few random I/O jumps).
The N most common values and their frequencies. Lets the planner know "50% of orders have status='pending'" so it can accurately estimate how many rows WHERE status = 'pending' will return.
| Scan Type | When Used | Cost Profile |
|---|---|---|
| Sequential Scan | No usable index, or when fetching most of the table (>~10-20% of rows) | Low per-page cost, predictable, benefits from OS read-ahead |
| Index Scan | Selective filter (<10% of rows), good index available | Random I/O per row — expensive for many rows, fast for few |
| Index-Only Scan | All needed columns in the index (covering index), visibility map is clean | Cheapest for covered queries — avoids heap entirely |
| Bitmap Index Scan | Moderate selectivity, especially with multiple indexes to combine (OR conditions) | Builds a bitmap in memory, then reads heap pages in order |
| Join Type | Algorithm | Best For |
|---|---|---|
| Nested Loop Join | For each row in outer, scan inner for matches. O(n×m). | Small inner relation, or inner has index on join column |
| Hash Join | Build hash table from smaller relation, probe with larger. O(n+m). | Large equi-joins, no useful index. Needs work_mem. |
| Merge Join | Sort both sides, merge in-order. O(n log n + m log m). | Both sides already sorted (e.g. indexed), or large equi-joins |
For the query SELECT * FROM users WHERE id = 42, the planner generates (at least) two candidate plans. Watch the optimizer compare their costs:
TRUNCATE a table and insert 10 million new rows without running ANALYZE, the planner still thinks the table has the old row count. It may choose a sequential scan when an index scan would be 1000× faster. Always run ANALYZE after bulk loads. autovacuum handles routine cases.
The optimizer doesn't time actual query execution to pick a plan. Instead, it assigns a cost — an abstract dimensionless number — to each candidate plan using a mathematical model. The plan with the lowest cost wins. Costs are calibrated relative to reading a single 8KB page sequentially from disk, which is assigned the cost of 1.0.
Total cost is the sum of I/O costs and CPU costs:
On a spinning hard disk, a random read requires a physical seek — the disk arm moves to a new track, then waits for the disk to rotate to the right sector. This takes ~5-10ms on average. A sequential read just continues from where it left off: no seek, no rotational wait. The 4× ratio approximates this difference for HDDs.
On SSDs, the random I/O penalty is much smaller — there's no mechanical seek. A well-tuned PostgreSQL on SSD should use random_page_cost = 1.1 or even 1.0. Using the default 4.0 on an SSD causes the optimizer to avoid index scans when it shouldn't, forcing unnecessary sequential scans.
random_page_cost = 1.1. If it's on NVMe, set random_page_cost = 1.0. This single change can dramatically improve query plan quality.
For queries joining N tables, there are N! possible join orderings (left-deep trees alone give N! permutations). PostgreSQL exhaustively tests all orderings for N ≤ 8 tables (controlled by join_collapse_limit). For N > 8, it switches to the Genetic Query Optimizer (GEQO).
Tests all possible join orderings and all possible access methods for each table. Optimal result guaranteed — the globally cheapest plan is always found.
geqo_threshold = 12 (switch to GEQO at 12+ tables)
Genetic algorithm: represents each join order as a "genome", performs crossover and mutation. Finds a good plan, not necessarily optimal. Fast for large numbers of tables.
geqo_effort = 5 (1-10 scale, 5 is default)
The planner's row estimates are only as good as the statistics. When ANALYZE runs, it samples a random subset of the table (default 300 × default_statistics_target = 30,000 rows) and computes histograms, MCV lists, and correlation values.
ANALYZE after bulk loads.
The executor takes the plan produced by the optimizer and actually executes it. PostgreSQL uses the Volcano iterator model (also called the iterator model or pipeline model), a design from a 1994 paper by Goetz Graefe that has become the standard for relational query engines.
Every node in the plan tree implements three operations:
Called once. Allocates memory, opens relations, initializes child nodes. Like a constructor.
Called repeatedly. Returns one tuple (row) each time, or NULL when done. The core of the model — pull semantics.
Called once at completion. Releases resources, closes files, frees memory. Like a destructor.
The root node of the plan tree is called by the executor driver. It calls GetNext() on its child, which calls GetNext() on its child, and so on down to the storage layer. Data flows upward through the tree one tuple at a time.
| Node Type | What It Does | Key Parameter |
|---|---|---|
| SeqScan | Read all pages of a heap table sequentially | relation OID |
| IndexScan | Traverse B-tree index, fetch heap pages per entry | index OID, scan keys |
| IndexOnlyScan | Traverse B-tree index, return from index leaf (no heap) | visibility map must be clean |
| BitmapIndexScan | Build in-memory bitmap of matching TIDs from index | Used with BitmapHeapScan |
| BitmapHeapScan | Read heap pages in order per the bitmap | recheck_cond if lossy |
| NestLoop | Nested loop join: outer × inner | join_filter |
| HashJoin | Build hash table, probe it | work_mem budget |
| MergeJoin | Merge-sort join of two sorted streams | merge keys |
| Sort | Sort tuples by key columns | work_mem, sort keys |
| HashAggregate | GROUP BY using a hash table | work_mem |
| Limit | Pass through N rows, then stop | count, offset |
| Gather | Merge results from parallel workers | num_workers |
The work_mem parameter controls how much memory each sort operation and each hash table can use before spilling to disk. The default is a shockingly low 4MB. A query with a sort, a hash join, and a group by uses up to 3 × work_mem = 12MB. With 100 concurrent connections each running such a query, that's 1.2GB just for these operations.
Since PostgreSQL 9.6, the executor can spawn parallel worker processes to split large sequential scans, joins, and aggregates across multiple CPU cores. The planner decides how many workers to use based on the estimated size of the work and the max_parallel_workers_per_gather setting.
An index is a separate data structure that trades write overhead and storage for faster reads. Without indexes, every query that filters rows must scan the entire table — O(n). With the right index, the same query runs in O(log n) or even O(1). Choosing the wrong index type — or putting an index on the wrong column — can actually make things worse.
When you run CREATE INDEX without specifying a type, you get a B-tree. It is the workhorse of relational databases, invented by Rudolf Bayer and Edward McCreight in 1972 and still the best general-purpose index structure available.
WHERE id = 42WHERE price BETWEEN 10 AND 50ORDER BY created_atWHERE name LIKE 'Ali%'(user_id, created_at)Index on (user_id, created_at) can answer:
WHERE user_id = 5WHERE user_id = 5 AND created_at > now() - '7d'WHERE created_at > now() - '7d' (can't use index — leading column missing)Leading column must appear in WHERE or the index can't be used for filtering.
Hash indexes use a hash function to map each indexed value to a bucket. This gives O(1) lookup for exact equality matches — theoretically faster than O(log n) B-tree for equality. However, hash indexes cannot do range scans (there's no ordering among hash buckets), cannot support ORDER BY, and were not WAL-logged until PostgreSQL 10 (making them unsafe on older versions).
In practice, B-tree indexes are so fast for equality (just 3-4 levels for millions of rows) that hash indexes are rarely worth the limitation. Use them only when you have a specific high-throughput equality-only workload and have benchmarked the improvement.
GiST is a framework for building custom index structures. It's used for:
point, polygon, circle — spatial queries like "find all locations within 10km"tsvector with @@ operatordaterange, int4range — overlap and containment queriesGIN is an inverted index — like the index at the back of a book, it maps each element (token, key) to the set of rows containing it. GIN is used for:
@>, ? operators)WHERE tags @> ARRAY['postgres', 'performance']BRIN (Block Range Index) is a tiny index designed for massive tables with natural physical ordering — think time-series data, sensor readings, log entries. Instead of storing individual values like B-tree, BRIN stores the min and max value for each range of consecutive disk pages.
Every UPDATE and DELETE in PostgreSQL is non-destructive. The old row version is marked as "dead" but the space is not immediately reclaimed. Dead tuples accumulate in both the heap (table data) and in all indexes pointing to those tuples. This is called index bloat.
Every page of data that PostgreSQL reads from disk is stored in the buffer pool (called shared_buffers in postgresql.conf). The buffer pool is a region of shared memory divided into 8KB slots — one slot per page. When the executor needs a page, it first checks the buffer pool. A cache hit returns the data in ~1 microsecond. A cache miss requires a disk read: 0.1ms on NVMe, 0.5ms on SSD, 5-10ms on HDD — up to 10,000× slower.
When the buffer pool is full and a new page needs to be loaded, PostgreSQL must evict an existing page. It uses a variant of the Least-Recently-Used (LRU) algorithm called the clock sweep. Each buffer has a "usage count" that increments when accessed and decrements during the clock sweep. Buffers with usage count of 0 are candidates for eviction.
Dirty pages (pages modified but not yet flushed to disk) must be written to disk before eviction. This is handled by the bgwriter (background writer) process, which proactively flushes dirty pages to reduce I/O pressure during eviction.
Watch cache hits (green) and misses (red/orange) in action. The cache hit ratio should be >99% in a healthy production database.
The default shared_buffers = 128MB is absurdly small for any production workload. The standard recommendation is 25% of total system RAM. On a 32GB server, set shared_buffers = 8GB. On a 128GB server, 32GB. PostgreSQL also relies on the OS page cache for the other 75% of RAM, so it's a two-layer cache.
The optimizer uses effective_cache_size (NOT shared_buffers) as its estimate of total memory available for caching. This includes both shared_buffers and the OS page cache. Set it to 75% of total RAM. This helps the optimizer correctly judge whether an index-only scan will have its pages in cache.
The Write-Ahead Log is PostgreSQL's durability engine. The fundamental rule: before any data page is modified, the change must first be written to the WAL. This is not a backup — it's an append-only sequential log of every change that enables crash recovery, point-in-time recovery, and streaming replication.
Without WAL, making a write durable would require doing a fsync() on every data page you modified — potentially many random writes spread across many data files. With WAL, you only need to fsync() the WAL file, which receives all changes as sequential appends. Sequential I/O is 10-100× faster than random I/O on both HDDs and SSDs.
Every WAL record has a unique Log Sequence Number (LSN) — a 64-bit byte offset into the WAL stream. LSNs are monotonically increasing. Every data page in PostgreSQL stores the LSN of the last WAL record that modified it, enabling precise crash recovery — replaying from the last checkpoint's LSN is sufficient.
| wal_level | What's Logged | Use Case |
|---|---|---|
| minimal | Only enough for crash recovery. No replication possible. | Single-server, maximum write performance (rarely appropriate) |
| replica | Full change data. Enables physical/streaming replication. | Default for production. Use for standby servers, PITR. |
| logical | Everything in replica + enough for logical decoding. | Logical replication, Change Data Capture (CDC), Debezium. |
A checkpoint is when PostgreSQL writes all dirty pages (modified in-memory buffers) to the actual data files on disk and records the checkpoint LSN in the control file. After a checkpoint, WAL replay on crash recovery only needs to start from the checkpoint LSN — earlier WAL can be recycled.
checkpoint_timeout (default 5 min)max_wal_size (default 1GB)CHECKPOINT commandcheckpoint_completion_target = 0.9 means PostgreSQL aims to write dirty pages over 90% of the checkpoint interval (not all at once), smoothing out I/O spikes. Set this to 0.9 in production.
wal_level=logical, the WAL decoder can produce row-level change events (INSERT/UPDATE/DELETE with old and new values). Tools like Debezium, pglogical, and Postgres logical replication slots consume this stream.EXPLAIN shows the query plan the optimizer chose. EXPLAIN ANALYZE actually executes the query and shows both the optimizer's estimates and the real execution numbers. The gap between estimated and actual rows is the first thing to look for — big gaps mean stale statistics.
EXPLAINShows the plan the optimizer picked. Estimated costs and row counts only. Does NOT execute the query. Safe to run on any query including destructive ones.
EXPLAIN ANALYZEExecutes the query AND shows the plan. Shows both estimated and actual rows/time. Running on UPDATE/DELETE actually makes the changes! Use in a transaction and ROLLBACK if needed.
EXPLAIN (BUFFERS, ANALYZE)The most detailed output. Shows buffer hits (shared_buffers), reads (disk/OS cache), and dirty/written counts. Essential for diagnosing cache performance.
Output (color-coded: costs, actual times, row counts):
The single most common database performance disaster. You fetch a list of 1000 users with one query, then for each user you issue a separate query to fetch their orders. Result: 1001 round trips to the database.
PostgreSQL does NOT automatically create indexes on foreign key columns (only on primary keys and unique constraints). A JOIN on orders.user_id = users.id will do a sequential scan of the orders table for every user if there's no index on orders.user_id.
SELECT * forces PostgreSQL to read and return every column of every row, including large TEXT, JSONB, and BYTEA columns. This increases I/O, network bandwidth, and memory usage. It also prevents Index-Only Scans since the executor must fetch the full row from the heap even when all needed data is in the index.
OFFSET 50000 LIMIT 10 does NOT magically skip 50,000 rows at the storage level. PostgreSQL must read and discard the first 50,000 rows before returning the 10 you want. For page 5000 of search results, you're reading 50,000 rows to return 10. This gets worse with every page.
Wrapping a column in a function call prevents PostgreSQL from using an index on that column. The index stores the raw column values, not the result of the function. PostgreSQL cannot efficiently find LOWER(email) = 'ali@example.com' using an index on email because the index stores mixed-case values.
When your application sends a string '42' but the column is integer, PostgreSQL casts the string to integer for each comparison. This usually works but can be catastrophic with indexes — if the column is text and your parameter is integer, the cast goes the wrong direction and kills the index.
EXPLAIN SELECT ...EXPLAIN ANALYZE SELECT ...EXPLAIN (ANALYZE, BUFFERS) ...EXPLAIN (ANALYZE, FORMAT JSON) ...EXPLAIN (ANALYZE, TIMING OFF) ...BEGIN; EXPLAIN ANALYZE DELETE ...; ROLLBACK;pg_stat_user_tablespg_stat_user_indexespg_stat_activitypg_stat_statementspg_lockspg_stat_bgwriterpg_stat_replication@@)CREATE INDEX ON t(col) WHERE active=true — tiny index for subset of rowsCREATE INDEX ON t(id) INCLUDE (name, email) — enables Index-Only Scanshared_buffers = 25% of RAMeffective_cache_size = 75% of RAMwork_mem = 64–256MB per sessionmaintenance_work_mem = 1–2GBwal_buffers = 64MBrandom_page_cost = 1.1 (SSD) or 4.0 (HDD)WHERE LOWER(col) = $1 — use functional indexWHERE col::date = $1 — use range insteadOFFSET n LIMIT 10 large n — use keyset paginationSELECT * on wide tables — select needed columnsSELECT and receiving your rows. Parser turns your characters into a tree. Rewriter expands views. Planner considers every possible strategy using table statistics. Optimizer assigns costs and picks the winner. Executor pulls rows through the plan tree. Buffer pool serves pages from RAM. WAL ensures no committed data is ever lost. Every single time.