DB Internals AtlasPostgreSQL 18.6 Home About

Query processing

Scan methods

A scan node is how PostgreSQL reads one table. Which one the planner picks decides how many pages are read, and in which order.

In plain words

A table is stored as a row of 8 kB pages, and rows sit on pages in no particular order. An index is a separate, sorted structure that says "value 12 is on page 7, slot 3".

There are a few ways to find rows: read every page from start to end (Seq Scan); look each value up in the index and jump to its page (Index Scan); answer from the index alone when it already holds every column you asked for (Index Only Scan); or first collect all the matching page numbers from the index, sort them, and then visit each page once in order (Bitmap Scan). If you already know the physical address of a row (its ctid), a TID Scan goes straight there.

Pick a method below and watch which pages get touched.

For DBAs and operators

Seq Scan

Reads blocks 0…N in order and evaluates the Filter on every visible tuple. Cheap per page and served by a read stream (PostgreSQL 17+) that combines neighbouring blocks into one read of up to io_combine_limit; since PostgreSQL 18 those reads are issued ahead with asynchronous I/O. Large scans use a small ring buffer so they do not evict the whole cache, and with synchronize_seqscans a second scan of the same big table joins the first one mid-way.

Index Scan

Descends the B-tree once per search (Index Searches in PostgreSQL 18 EXPLAIN), walks matching leaf entries in key order and fetches each heap tuple by TID to check visibility. Output is sorted by the index key, which can save a Sort. Cost grows with random heap reads unless the table is physically correlated with the index.

Index Only Scan

Possible when every referenced column can be returned by the index (including payload columns supplied with INCLUDE, where the access method supports index-only scans). For each entry it checks the page's bit in the visibility map: an all-visible heap page needs no per-tuple heap visibility check; otherwise the heap is visited. Watch Heap Fetches: a high number means many referenced pages are not all-visible, which can be normal on frequently modified data or can justify reviewing vacuum cadence.

Index Only Scan using orders_pkey on orders (actual rows=999.00 loops=1)
  Index Cond: (id < 1000)
  Heap Fetches: 0
  Index Searches: 1
  Buffers: shared hit=4 read=2

Bitmap Index Scan + Bitmap Heap Scan

The index scan builds an in-memory TID bitmap (one entry per heap page, with per-tuple bits). Several bitmaps can be combined with BitmapAnd / BitmapOr, e.g. for a = 1 OR b = 2. The heap scan then visits pages in physical order, each at most once, and can prefetch them (effective_io_concurrency). If the bitmap would exceed work_mem, pages become lossy: only "something matches on this page" is remembered, so every tuple there must be rechecked.

SET work_mem = '64kB';
Bitmap Heap Scan on orders (actual rows=203374.00 loops=1)
  Recheck Cond: ((created >= '2024-03-01'::date) AND (created <= '2024-04-30'::date))
  Rows Removed by Index Recheck: 393133
  Heap Blocks: exact=967 lossy=3641
  ->  Bitmap Index Scan on orders_created_idx (actual rows=203374.00 loops=1)

TID Scan and TID Range Scan

WHERE ctid = '(5,2)' fetches block 5, line pointer 2 directly; ctid >= '(1000,0)' AND ctid < '(2000,0)' (TID Range Scan, PostgreSQL 14+) reads a block range, which is handy for batching big deletes or updates. A ctid changes when a row is updated or the table is rewritten, so never store it.

Diagnosing

  • Rows Removed by Filter large on a Seq Scan of a big table → missing or unusable index (function on the column, type mismatch, leading wildcard LIKE '%x').
  • enable_seqscan = off etc. do not forbid a method: since PostgreSQL 18 the planner counts disabled nodes and prefers plans with fewer of them, but still uses a disabled method when there is no alternative (EXPLAIN then shows Disabled: true). Use them in a session to compare plans, never globally.
  • pg_stat_user_tables.seq_scan / idx_scan and pg_stat_user_indexes.idx_scan show which methods are used in practice.

For developers: inside the source

Where it lives

One executor file per node in src/backend/executor/: nodeSeqscan.c, nodeIndexscan.c, nodeIndexonlyscan.c, nodeBitmapIndexscan.c, nodeBitmapHeapscan.c, nodeTidscan.c, nodeTidrangescan.c. Each fetches tuples through an access-method function (SeqNext(), IndexNext(), …) wrapped by ExecScan() / ExecScanExtended() (execScan.c), which evaluates the qual and projection. Since PostgreSQL 18 ExecInitSeqScan() installs a specialised variant (ExecSeqScan, ExecSeqScanWithQual, ExecSeqScanWithProject, ExecSeqScanWithQualProject).

Below the executor sit the table AM (tableam.h, heap implementation in heapam_handler.c and heapam.c) and the index AM API (IndexAmRoutine in amapi.h: amgettuple, amgetbitmap, amcanreturn, amcostestimate) with generic wrappers in indexam.c.

Call flow per method

  • Seq Scan: SeqNext() → table_scan_getnextslot() → heap_getnextslot() → heapgettup_pagemode() → heap_fetch_next_buffer() → read_stream_next_buffer(); the stream callback heap_scan_stream_read_next_serial() (or _parallel) supplies block numbers. initscan() picks a BAS_BULKREAD ring when the table is larger than NBuffers / 4 and gets the start block from ss_get_location() (syncscan.c).
  • Index Scan: IndexNext() → index_getnext_slot() → index_getnext_tid() (calls amgettuple, e.g. btgettuple()) → index_fetch_heap() → heapam_index_fetch_tuple(), which follows HOT chains with heap_hot_search_buffer().
  • Index Only Scan: IndexOnlyNext() calls index_getnext_tid(), tests VM_ALL_VISIBLE() itself and calls index_fetch_heap() only when the bit is clear; column values come from xs_itup or xs_hitup.
  • Bitmap: MultiExecBitmapIndexScan() creates the bitmap with tbm_create(work_mem * 1024, …) and fills it via index_getbitmap() → btgetbitmap() → tbm_add_tuples() (tidbitmap.c); tbm_lossify() turns exact page entries into lossy chunks when the budget is exceeded. BitmapHeapNext() → table_scan_bitmap_next_tuple() → heapam_scan_bitmap_next_tuple(); since PostgreSQL 18 the heap pages come from a read stream (bitmapheap_stream_read_next()).
  • TID: TidNext() and TidRangeNext().

Observe it

  • pageinspect (heap_page_items(), bt_page_items()) shows line pointers, ctids and index entries; pg_visibility shows the VM bits that decide Heap Fetches; pg_buffercache shows which pages a scan pulled into shared buffers.
  • READMEs: nbtree/README, heap/README.HOT, and storage/aio/README.md for the read stream.
  • Regression tests: src/test/regress/sql/tidscan.sql, tidrangescan.sql, bitmapops.sql, btree_index.sql, create_index.sql.

Parameters that shape this

enable_seqscan enable_indexscan enable_indexonlyscan enable_bitmapscan enable_tidscan work_mem random_page_cost effective_io_concurrency synchronize_seqscans

Related topics

Planner cost model B-tree indexes Free space map and visibility map Page layout Asynchronous I/O VACUUM Parallel query