pgconfigurator
pgconfigurator

Tid Scan

Appears in EXPLAIN asTid Scan

Fetches rows directly by their physical location (ctid).

What it is

A Tid Scan looks rows up by their tuple identifier (ctid) — the physical (block, offset) address of a row — so it can jump straight to the page without an index.

When the planner picks it

When the query filters on ctid directly, e.g. WHERE ctid = '(0,1)'.

Is it good or bad?

Extremely fast for the rows it targets, but ctids are not stable: UPDATE, VACUUM FULL, and CLUSTER move rows. Don't store ctids as if they were durable keys.

In depth

Reading rows by their address

A Tid Scan jumps directly to rows by CTID — a row's physical (block, offset) location in the heap. There's no index lookup; PostgreSQL goes straight to the page and returns the tuple.

SELECT * FROM big WHERE ctid = '(0, 1)';
SELECT * FROM big WHERE ctid IN ('(0, 1)', '(5, 12)', '(99, 3)');

It's the fastest possible single-row fetch — one page read, no traversal — which is why some chunked batch jobs use CTID lists to drive parallel work.

CTIDs are not stable

The catch is everything: a row's CTID can change. Any UPDATE may move the row (a non-HOT update writes a new tuple at a new location); VACUUM FULL, CLUSTER, and pg_repack rewrite the entire table; a logical replica may have entirely different CTIDs for the same rows.

So never store CTIDs as if they were durable keys. Use them for transient work — a one-pass batch job that produces a CTID list, processes it immediately, and never persists it.

When the planner picks it

The planner picks a Tid Scan when the WHERE clause filters on ctid directly. It's not a fallback for queries on a normal column — for "find this row," a normal index works and is much more useful than CTID.

For range work over physical layout, see Tid Range Scan.

See also