How PostgreSQL Skip Scans B-Tree Indexes
With PostgreSQL 18's skip scan, an index on (a, b) can serve a query on b alone by procedurally generating the possible a values, which can be orders of magnitude faster than a full index scan.
A B-tree index on (a, b) is a sorted list of pairs. Sorting by a
first means all the rows with a = 1 are contiguous, all the rows with
a = 2 are contiguous, and so on — and within each of those groups, the
rows are sorted by b.
Now consider the query SELECT * FROM t WHERE b = 5. There is exactly one
row per a value that satisfies it, scattered all over the index, one
per group. The index knows how to find a = 1, b = 5 in logarithmic
time. It has no idea how to find b = 5 alone, because b is not the
leading column.
This is the oldest limitation in multi-column B-tree indexing, and it is why people end up with four indexes. PostgreSQL 18 changed it. The feature is called skip scan, and it is one of the more interesting pieces of work in the nbtree access method — not because of the idea, which is obvious, but because of how carefully the implementation avoids making ordinary scans slower.
This is a walk through how it works, based on reading the PostgreSQL
source (version 20devel, commit e75fcc9867f).
The idea, stated plainly
If you want to find b = 5 in an index on (a, b), and the number of
distinct a values is small, you can just do:
for each distinct value of a:
look up (a, 5)The commit that introduced the feature (92fe23d9, “Add nbtree skip
scan optimization”) states it exactly:
Teach nbtree multi-column index scans to opportunistically skip over irrelevant sections of the index given a query with no “=” conditions on one or more prefix index columns. When nbtree is passed input scan keys derived from a predicate “WHERE b = 5”, new nbtree preprocessing steps output “WHERE a = ANY(<every possible ‘a’ value>) AND b = 5” scan keys. That is, preprocessing generates a “skip array” (and an output scan key) for the omitted prefix column “a”, which makes it safe to mark the scan key on “b” as required to continue the scan. The scan is therefore able to repeatedly reposition itself by applying both the “a” and “b” keys.
The critical phrase is “makes it safe to mark the scan key on b as
required”. In a normal B-tree scan, a key is required if the scan can
stop as soon as it is violated. With an unconstrained leading column, the
b = 5 key cannot be required: the index is not sorted by b globally,
so failing b = 5 tells you nothing about whether a later tuple might
match. That is precisely why a scan with WHERE b = 5 on (a, b) has
to read the whole index.
Add an artificial equality constraint on a — “a equals some value” —
and the index is sorted by b within each a group. Now b = 5 is a
required key, and the scan can stop as soon as it leaves the group.
The obstacle: nobody knows what “every possible a value” is, and there
could be ten million of them. So the “array” is not materialized. It is
generated on demand, one element at a time, as the scan runs. That is
what a skip array is.
What the planner already did (and didn’t) do
It is worth being precise here, because “the planner falls back to a full index scan or a sequential scan” is only half true.
The planner does match a non-leading column’s qual to the index.
match_clause_to_index() in src/backend/optimizer/path/indxpath.c
tries every index column in turn:
/* OK, check each index key column for a match */
for (indexcol = 0; indexcol < index->nkeycolumns; indexcol++)
{
IndexClause *iclause;
ListCell *lc;
/* Ignore duplicates */
foreach(lc, clauseset->indexclauses[indexcol])
{
iclause = (IndexClause *) lfirst(lc);
if (iclause->rinfo == rinfo)
return;
}
/* OK, try to match the clause to the index column */
iclause = match_clause_to_indexcol(root,
rinfo,
indexcol,
index);and build_index_paths() collects the matched clauses from every
column into one list:
index_clauses = NIL;
outer_relids = bms_copy(rel->lateral_relids);
for (indexcol = 0; indexcol < index->nkeycolumns; indexcol++)
{
ListCell *lc;
foreach(lc, clauses->indexclauses[indexcol])
{
IndexClause *iclause = (IndexClause *) lfirst(lc);
...
/* OK to include this clause */
index_clauses = lappend(index_clauses, iclause);So the qual on b is passed to the index AM as an index qual. What
the AM does with it is up to the AM. Before skip scan, nbtree would
evaluate b = 5 against every tuple in the index and could not use it to
stop early — a full index scan, but a scan that at least avoided the heap.
There is a real regression test in the tree that shows the modern
behaviour. From src/test/regress/sql/btree_index.sql:
-- Test for skip scan with type that lacks skip support (text)
--
set enable_seqscan to false;
set enable_bitmapscan to false;
-- Forwards scan
SELECT id FROM btree_tall_tbl WHERE id = 55 ORDER BY t, id;against an index on (t text, id int4). The expected plan
(src/test/regress/expected/btree_index.out) is:
QUERY PLAN
--------------------------------------------------------
Index Only Scan using btree_tall_idx on btree_tall_tbl
Index Cond: (id = 55)Index Cond: (id = 55) on an index whose leading column is t. The
planner does not label it as a skip scan, does not produce a second
competing path, and does not change the plan shape depending on whether
the scan actually manages to skip. It produces one index scan, and the
AM decides at runtime how to execute it.
That design decision was deliberate, and it is the reason the feature
could be merged without a lot of planner churn. As the follow-up commit
8a510275dd6 puts it:
Skip scan adds skip arrays during preprocessing using simple, static rules, and decides how best to navigate/apply the scan’s skip arrays dynamically, at runtime. The “startikey” optimization enables this approach. As a result of all this, the planner doesn’t need to generate distinct, competing index paths (one path for skip scan, another for an equivalent traditional full index scan). The overall effect is to make scan runtime close to optimal, even when the planner works off an incorrect cardinality estimate.
One path, runtime decisions. That is a much better design than two paths and a planner bet.
Generating the skip array
The skip array is created during scan key preprocessing, in
src/backend/access/nbtree/nbtpreprocesskeys.c. This is the code that
every B-tree index scan runs through before it touches the index.
The file’s header comment explains the whole scheme, including the part that motivates skip arrays in the first place:
If we never generated skip array scan keys, it would be possible for “gaps” to appear that make it unsafe to mark any subsequent input scan keys (copied from scan->keyData[]) as required to continue the scan. Prior to Postgres 18, a qual like “WHERE y = 4” always resulted in a full scan. This qual now becomes “WHERE x = ANY(’{every possible x value}’) and y = 4” on output. In other words, preprocessing now adds a skip array on “x”. This has the potential to be much more efficient than a full index scan (though it behaves like a full scan when there’s many distinct “x” values).
And it explains the cost of the feature, honestly:
Skip array = keys will even be generated in the presence of “contradictory” inequality quals when it’ll enable marking later input quals as required. We’ll merge any such inequalities into the generated skip array by setting its array.low_compare or array.high_compare key field. The resulting skip array will generate its array elements from a range that’s constrained by any merged input inequalities (which won’t get output in so->keyData[]).
Deciding which columns get skip arrays
_bt_num_array_keys() does the planning of which columns need a skip
array. Its header comment is the clearest statement of the rules in the
whole feature:
Also sets *numSkipArrayKeys_out to the number of skip arrays caller must add to the scan keys it’ll output. Caller must add this many skip arrays: one array for each of the most significant attributes that lack a = input key (IS NULL keys count as = input keys here). The specific attributes that need skip arrays are indicated by initializing skip_eq_ops_out[] arg 0-based attribute offset to a valid = op strategy Oid. We’ll only ever set skip_eq_ops_out[] entries to InvalidOid for attributes that already have an equality key in scan->keyData[] input keys – and only when there’s some later “attribute gap” for us to “fill-in” with a skip array.
We’re optimistic about skipping working out: we always add exactly the skip arrays needed to maximize the number of input scan keys that can ultimately be marked as required to continue the scan (but no more). Given a multi-column index on (a, b, c, d), we add skip arrays as follows:
Input keys Output keys (after all preprocessing)
a = 1 a = 1 (no skip arrays) b = 42 skip a AND b = 42 a = 1 AND b = 42 a = 1 AND b = 42 (no skip arrays) a >= 1 AND b = 42 range skip a AND b = 42 a = 1 AND b > 42 a = 1 AND b > 42 (no skip arrays) a >= 1 AND a <= 3 AND b = 42 range skip a AND b = 42 a = 1 AND c <= 27 a = 1 AND skip b AND c <= 27 a = 1 AND d >= 1 a = 1 AND skip b AND skip c AND d >= 1 a = 1 AND b >= 42 AND d > 1 a = 1 AND range skip b AND skip c AND d > 1
Read that table carefully; it is the specification. Two rules fall out:
- A skip array is added for a leading column only if a later column has
a qual. If
b = 42is the last qual, a skip array onais pointless — there is nothing after it that could become required. The last column never gets a skip array. - A skip array’s range is constrained by inequality quals on the same
column.
a >= 1 AND b = 42produces a range skip array onathat only generates values ≥ 1.
The implementation walks the input keys and back-fills:
/*
* Backfill skip arrays for any wholly omitted attributes prior to
* attno_inkey
*/
while (attno_skip < attno_inkey)
{
Oid opfamily = rel->rd_opfamily[attno_skip - 1];
Oid opcintype = rel->rd_opcintype[attno_skip - 1];
/* Look up input opclass's equality operator (might fail) */
skip_eq_ops_out[attno_skip - 1] =
get_opfamily_member(opfamily, opcintype, opcintype,
BTEqualStrategyNumber);
if (!OidIsValid(skip_eq_ops_out[attno_skip - 1]))
{
/*
* Cannot generate a skip array for this or later attributes
* (input opclass lacks an equality strategy operator)
*/
*numSkipArrayKeys_out = prev_numSkipArrayKeys;
return numSAOPArrayKeys + prev_numSkipArrayKeys;
}
/* plan on adding a backfill skip array for this attribute */
numSkipArrayKeys++;
attno_skip++;
}Note the bail-out: if the opclass has no equality operator, no skip array is possible for that column or any later column. This is why skip scan does not work for every type.
The result is recorded on the scan:
numArrayKeys = _bt_num_array_keys(scan, skip_eq_ops, &numSkipArrayKeys);
so->skipScan = (numSkipArrayKeys > 0);so->skipScan is the flag the runtime uses to know it is dealing with a
skip scan, and it is consulted in exactly one place in the read path
(section on startikey below).
Building the scan key
The actual scan key is constructed in _bt_preprocess_array_keys():
cmp_proc = get_opcode(eq_op);
if (!RegProcedureIsValid(cmp_proc))
elog(ERROR, "missing oprcode for skipping equals operator %u", eq_op);
ScanKeyEntryInitialize(cur,
SK_SEARCHARRAY | SK_BT_SKIP, /* flags */
attno_skip, /* skipped att number */
BTEqualStrategyNumber, /* equality strategy */
InvalidOid, /* opclass input subtype */
collation, /* index column's collation */
cmp_proc, /* equality operator's proc */
(Datum) 0); /* constant */
/* Initialize generic BTArrayKeyInfo fields */
so->arrayKeys[numArrayKeys].scan_key = numArrayKeyData;
so->arrayKeys[numArrayKeys].num_elems = -1;
/* Initialize skip array specific BTArrayKeyInfo fields */
attr = TupleDescCompactAttr(RelationGetDescr(rel), attno_skip - 1);
reverse = (indoption[attno_skip - 1] & INDOPTION_DESC) != 0;
so->arrayKeys[numArrayKeys].attlen = attr->attlen;
so->arrayKeys[numArrayKeys].attbyval = attr->attbyval;
so->arrayKeys[numArrayKeys].null_elem = true; /* for now */
so->arrayKeys[numArrayKeys].sksup =
PrepareSkipSupportFromOpclass(opfamily, opcintype, reverse);
so->arrayKeys[numArrayKeys].low_compare = NULL; /* for now */
so->arrayKeys[numArrayKeys].high_compare = NULL; /* for now */Three things to notice:
num_elems = -1is the marker that distinguishes a skip array from an ordinaryScalarArrayOpExprarray. TheBTArrayKeyInfostruct says so plainly:
typedef struct BTArrayKeyInfo
{
/* fields set for both kinds of array (SAOP arrays and skip arrays) */
int scan_key; /* index of associated key in keyData */
int num_elems; /* number of elems (-1 means skip array) */
/* fields set for ScalarArrayOpExpr arrays only */
Datum *elem_values; /* array of num_elems Datums */
int cur_elem; /* index of current element in elem_values */
/* fields set for skip arrays only */
int16 attlen; /* attr's length, in bytes */
bool attbyval; /* attr's FormData_pg_attribute.attbyval */
bool null_elem; /* NULL is lowest/highest element? */
SkipSupport sksup; /* skip support (NULL if opclass lacks it) */
ScanKey low_compare; /* array's > or >= lower bound */
ScanKey high_compare; /* array's < or <= upper bound */
} BTArrayKeyInfo;- The scan key gets the
SK_BT_SKIPflag, defined insrc/include/access/nbtree.halongside the other nbtree-specific scan key flags:
#define SK_BT_REQFWD 0x00010000 /* required to continue forward scan */
#define SK_BT_REQBKWD 0x00020000 /* required to continue backward scan */
#define SK_BT_SKIP 0x00040000 /* skip array on column without input = */
/* SK_BT_SKIP-only flags (set and unset by array advancement) */
#define SK_BT_MINVAL 0x00080000 /* invalid sk_argument, use low_compare */
#define SK_BT_MAXVAL 0x00100000 /* invalid sk_argument, use high_compare */
#define SK_BT_NEXT 0x00200000 /* positions the scan > sk_argument */
#define SK_BT_PRIOR 0x00400000 /* positions the scan < sk_argument */sksupis the opclass’s skip support, and it may be NULL.
Making the keys required
After preprocessing, the keys are marked required if they can be. The
rule is in _bt_mark_scankey_required():
switch (skey->sk_strategy)
{
case BTLessStrategyNumber:
case BTLessEqualStrategyNumber:
addflags = SK_BT_REQFWD;
break;
case BTEqualStrategyNumber:
addflags = SK_BT_REQFWD | SK_BT_REQBKWD;
break;An equality key is required in both directions — it is exactly the
constraint that makes the scan able to stop. This is the payoff of the
skip array: the b = 5 key becomes an equality key, and it becomes
required, and the scan can terminate.
There is an invariant worth quoting, from _bt_preprocess_array_keys_final():
/*
* All skip arrays must be marked required, and final column can
* never have a skip array
*/
Assert(array->num_elems > 0 || array->num_elems == -1);
Assert(array->num_elems != -1 || outkey->sk_flags & SK_BT_REQFWD);
Assert(array->num_elems != -1 ||
outkey->sk_attno < IndexRelationGetNumberOfKeyAttributes(rel));Every skip array is required; the last column never has one. Those two invariants are the whole specification, restated as assertions.
Skip support: generating the values
Now the interesting part: how does the scan produce “the next value of
a”?
There are two mechanisms.
The generic one: sentinel flags
If the opclass has no skip support function, the scan cannot compute the
next value of a. Instead, it sets a flag saying “position the scan
somewhere after the current element” and lets the index itself find the
next value that actually exists:
/*
* Opclasses without skip support "increment" the scan key's current
* element by setting the NEXT flag. The true next value is determined by
* repositioning to the first index tuple > existing sk_argument/current
* array element. Note that this works in the usual way when the scan key
* is already marked ISNULL (i.e. when the current element is NULL).
*/
if (!array->sksup)
{
/* Successfully "incremented" array */
skey->sk_flags |= SK_BT_NEXT;
return true;
}SK_BT_NEXT means “position strictly after the current element”;
SK_BT_PRIOR is the backwards-scan equivalent; SK_BT_MINVAL and
SK_BT_MAXVAL mean “the current element is the lowest/highest value in
the array’s range, use low_compare/high_compare instead”.
This is the mechanism the text regression test above exercises, since
text has no skip support.
The clever one: opclass support functions
For discrete types — integers, dates, oids, timestamps, uuids — the
opclass can supply a function that knows how to step to the next value.
The interface is in src/include/utils/skipsupport.h:
B-Tree operator classes for discrete types can optionally provide a support function for skipping. This is used during skip scans.
A B-tree operator class that implements skip support provides B-tree index scans with a way of enumerating and iterating through every possible value from the domain of indexable values. This gives scans a way to determine the next value in line for a given skip array/scan key/skipped attribute. Scans request the next (or previous) value whenever they run out of tuples matching the skip array’s current element value. The next (or previous) value can be used to relocate the scan; it is applied in combination with at least one additional lower-order non-skip key, taken from the query.
The structure is small:
typedef struct SkipSupportData
{
/*
* low_elem and high_elem must be set with the lowest and highest possible
* values from the domain of indexable values (assuming ascending order)
*/
Datum low_elem; /* lowest sorting/leftmost non-NULL value */
Datum high_elem; /* highest sorting/rightmost non-NULL value */
/*
* Decrement/increment functions.
* ...
*/
SkipSupportIncDec decrement;
SkipSupportIncDec increment;
} SkipSupportData;The int4 implementation is in src/backend/access/nbtree/nbtcompare.c:
Datum
btint4skipsupport(PG_FUNCTION_ARGS)
{
SkipSupport sksup = (SkipSupport) PG_GETARG_POINTER(0);
sksup->decrement = int4_decrement;
sksup->increment = int4_increment;
sksup->low_elem = Int32GetDatum(PG_INT32_MIN);
sksup->high_elem = Int32GetDatum(PG_INT32_MAX);
PG_RETURN_VOID();
}with the increment being:
static Datum
int4_increment(Relation rel, Datum existing, bool *overflow)
{
int32 iexisting = DatumGetInt32(existing);
if (iexisting == PG_INT32_MAX)
{
/* return value is undefined */
*overflow = true;
return (Datum) 0;
}
*overflow = false;
return Int32GetDatum(iexisting + 1);
}The opclasses that provide this are registered as a sixth amproc,
BTSKIPSUPPORT_PROC:
/*
* To facilitate more efficient B-Tree skip scans, an operator class may
* choose to offer a sixth amproc procedure (BTSKIPSUPPORT_PROC). For full
* details, see src/include/utils/skipsupport.h.
*/
#define BTORDER_PROC 1
#define BTSORTSUPPORT_PROC 2
#define BTINRANGE_PROC 3
#define BTEQUALIMAGE_PROC 4
#define BTOPTIONS_PROC 5
#define BTSKIPSUPPORT_PROC 6The header comment on the whole idea is worth repeating, because it explains the performance model:
Indexes with an attribute whose input opclass is of one of these types tend to store adjacent values in adjoining groups of index tuples. Each time a skip scan with skip support successfully guesses that the next value in the index (for a given skipped column) is indeed the value that skip support just incremented its skip array to, it will have saved the scan some work. The scan will have avoided an index probe that directly finds the next value that appears in the index. (When skip support guesses wrong, then it won’t have saved any work, but it also won’t have added any useless work. The failed attempt to locate exactly-matching index tuples acts just like an explicit probe would; it’ll still find the index’s true next value.)
That is the whole trick in one sentence: guessing the next value is free, and being wrong costs nothing extra — because a failed guess degenerates into exactly the index probe you would have done anyway.
For a DESC column the support is flipped, so that “increment” still
means “move forward in scan order”:
if (reverse)
{
/*
* DESC/reverse case: swap low_elem with high_elem, and swap decrement
* with increment
*/
Datum low_elem = sksup->low_elem;
SkipSupportIncDec decrement = sksup->decrement;
sksup->low_elem = sksup->high_elem;
sksup->decrement = sksup->increment;
sksup->high_elem = low_elem;
sksup->increment = decrement;
}Runtime: advancing the array
The scan advances its skip array when it runs out of tuples matching the
current element. The core is _bt_array_increment() in
src/backend/access/nbtree/nbtreadpage.c:
/*
* Opclasses with skip support directly increment sk_argument
*/
if (skey->sk_flags & SK_ISNULL)
{
Assert(skey->sk_flags & SK_BT_NULLS_FIRST);
/*
* Existing sk_argument/array element is NULL (for an IS NULL qual).
*
* "Increment" from NULL to the low_elem value provided by opclass
* skip support routine.
*/
skey->sk_flags &= ~(SK_SEARCHNULL | SK_ISNULL);
skey->sk_argument = datumCopy(array->sksup->low_elem,
array->attbyval, array->attlen);
return true;
}
/*
* Ask opclass support routine to provide incremented copy of existing
* non-NULL sk_argument
*/
inc_sk_argument = array->sksup->increment(rel, skey->sk_argument, &oflow);
if (unlikely(oflow))
{
/* inc_sk_argument has undefined value (so no pfree) */
if (array->null_elem && !(skey->sk_flags & SK_BT_NULLS_FIRST))
{
_bt_skiparray_set_isnull(rel, skey, array);
/* Successfully "incremented" array to NULL */
return true;
}
/* Cannot increment past final array element */
return false;
}and the range check that keeps a range skip array inside its bounds:
/*
* Successfully incremented sk_argument to a non-NULL value. Make sure
* that the incremented value is still within the range of the array.
*/
if (array->high_compare &&
!DatumGetBool(FunctionCall2Coll(&array->high_compare->sk_func,
array->high_compare->sk_collation,
inc_sk_argument,
array->high_compare->sk_argument)))
{
/* Keep existing sk_argument after all */
if (!array->attbyval)
pfree(DatumGetPointer(inc_sk_argument));
/* Cannot increment past final array element */
return false;
}Snapping to values that exist
A subtlety: the skip array’s “current element” does not have to be a
value that actually appears in the index. _bt_skiparray_set_element()
sets the current element from whatever the scan just saw:
static void
_bt_skiparray_set_element(Relation rel, ScanKey skey, BTArrayKeyInfo *array,
int32 set_elem_result, Datum tupdatum, bool tupnull)
{
Assert(skey->sk_flags & SK_BT_SKIP);
Assert(skey->sk_flags & SK_SEARCHARRAY);
if (set_elem_result)
{
/* tupdatum/tupnull is out of the range of the skip array */
Assert(!array->null_elem);
_bt_array_set_low_or_high(rel, skey, array, set_elem_result < 0);
return;
}
/* Advance skip array to tupdatum (or tupnull) value */
if (unlikely(tupnull))
{
_bt_skiparray_set_isnull(rel, skey, array);
return;
}
/* Free memory previously allocated for sk_argument if needed */
if (!array->attbyval && skey->sk_argument)
pfree(DatumGetPointer(skey->sk_argument));
/* tupdatum becomes new sk_argument/new current element */
skey->sk_flags &= ~(SK_SEARCHNULL | SK_ISNULL |
SK_BT_MINVAL | SK_BT_MAXVAL |
SK_BT_NEXT | SK_BT_PRIOR);
skey->sk_argument = datumCopy(tupdatum, array->attbyval, array->attlen);
}In other words: when the scan finds a tuple whose a value is 7, the
skip array’s current element becomes 7 — not 6, not 8. The scan has
discovered the real next value by reading it. This is why skip support
can be a pure optimization: if the guess was wrong, the scan learns the
truth from the index.
The whole advancement machinery is driven from
_bt_advance_array_keys(), which is also where the scan decides whether
to schedule another “primitive” index scan:
/*
* Advance the array keys incrementally whenever "beyond end of array
* element" array advancement happens, so that advancement will carry to
* higher-order arrays (might exhaust all the scan's arrays instead, which
* ends the top-level scan).
*/
if (beyond_end_advance &&
!_bt_advance_array_keys_increment(scan, dir, &skip_array_advanced))
goto end_toplevel_scan;Positioning the scan
The first descent into the index has to work out where to start. That is
_bt_first() in src/backend/access/nbtree/nbtsearch.c, and its comment
describes the skip-array case in detail:
For example, a forward scan with a skip array on its leading attribute (with no low_compare/high_compare) will have at least two required scan keys, but we won’t use any of them as boundary keys during the scan’s initial call here. Our positioning key during the first call here can be thought of as representing “> -infinity”. Similarly, if such a skip array’s low_compare is “a > ‘foo’”, then we position using “a > ‘foo’” during the scan’s initial call here; a lower-order key such as “b = 42” can’t be used until the “a” array advances beyond MINVAL/low_compare.
On the other hand, if such a skip array’s low_compare was “a >= ‘foo’”, then we can use “a >= ‘foo’ AND b = 42” during the initial call here. A subsequent call here might have us use “a = ‘fop’ AND b = 42”.
The code that implements this picks between the skip array’s
low_compare and high_compare depending on scan direction:
if (bkey != NULL &&
(bkey->sk_flags & (SK_BT_MINVAL | SK_BT_MAXVAL)))
{
int ikey = bkey - so->keyData;
ScanKey skipequalitykey = bkey;
BTArrayKeyInfo *array = NULL;
for (int arridx = 0; arridx < so->numArrayKeys; arridx++)
{
array = &so->arrayKeys[arridx];
if (array->scan_key == ikey)
break;
}
if (ScanDirectionIsForward(dir))
{
Assert(!(skipequalitykey->sk_flags & SK_BT_MAXVAL));
bkey = array->low_compare;
}
else
{
Assert(!(skipequalitykey->sk_flags & SK_BT_MINVAL));
bkey = array->high_compare;
}So the initial descent uses the best available boundary, and subsequent
repositionings use the concrete current element of the skip array. That
is the “repeated WHERE x = N AND y = 7700 searches” behaviour the
documentation describes:
For example, given an index on
(x, y), and a query conditionWHERE y = 7700, a B-tree index scan might be able to apply the skip scan optimization. This generally happens when the query planner expects that repeatedWHERE x = N AND y = 7700searches for every possible value ofN(or for everyxvalue that is actually stored in the index) is the fastest possible approach, given the available indexes on the table.
The startikey optimization
Here is the part that makes the feature safe to enable by default.
Without any special handling, adding a skip array to every scan that has a “gap” in its leading columns would slow down every ordinary scan. The cost is real: a skip array has to be maintained — checked, advanced, possibly re-checked — for every tuple the scan visits. On a scan that cannot actually skip anything, that maintenance is pure overhead.
The fix, in commit 8a510275dd6 (“Further optimize nbtree search scan
key comparisons”), is the startikey optimization. The idea: on a
given leaf page, work out which scan keys are guaranteed to be satisfied
by every tuple on that page, and skip the comparison entirely.
_bt_set_startikey() in src/backend/access/nbtree/nbtreadpage.c does
the work:
/*
* Determines an offset to the first scan key (an so->keyData[]-wise offset)
* that is _not_ guaranteed to be satisfied by every tuple from pstate.page,
* which is set in pstate.startikey for _bt_checkkeys calls for the page.
* This allows caller to save cycles on comparisons of a prefix of keys while
* reading pstate.page.
*
* Also determines if later calls to _bt_checkkeys (for pstate.page) should be
* forced to treat all required scan keys >= pstate.startikey as nonrequired
* (that is, if they're to be treated as if any SK_BT_REQFWD/SK_BT_REQBKWD
* markings that were set by preprocessing were not set at all, for the
* duration of _bt_checkkeys calls prior to the call for pstate.finaltup).
* This is indicated to caller by setting pstate.forcenonrequired.
*/The implementation looks at the first and last non-pivot tuples on the
page, finds the first attribute whose values differ between them
(_bt_keep_natts_fast()), and then walks the scan keys deciding which
ones are safe to skip.
For a scalar = key it is conservative:
if (!(key->sk_flags & SK_SEARCHARRAY))
{
/*
* Scalar = key (possibly an IS NULL key).
*
* It is unsafe to set pstate.startikey to an ikey beyond this
* key, unless the = key is satisfied by every possible tuple on
* the page (possible only when attribute has just one distinct
* value among all tuples on the page).
*/
if (key->sk_attno >= firstchangingattnum)
break; /* unsafe, multiple distinct attr values */For a non-range skip array it is permissive, because such an array is “satisfied” by every tuple on the page by construction:
/*
* Skip array = key
*/
Assert(key->sk_flags & SK_BT_SKIP);
if (array->null_elem)
{
/*
* Non-range skip array = key.
*
* Safe, non-range skip array "satisfied" by every tuple on page
* (safe even when "key->sk_attno > firstchangingattnum").
*/
continue;
}And the second half of the optimization — suppressing the “required” protocol for the duration of the page — is this one line:
pstate->forcenonrequired = (start_past_saop_eq || so->skipScan);
pstate->startikey = startikey;so->skipScan is the flag set during preprocessing. So for a skip scan,
the scan always suppresses the required-key protocol while reading a
page, which is exactly where the per-tuple array maintenance cost would
otherwise be paid. The scan still has to maintain the array correctly,
so the caller must re-synchronize at the end of the page:
/*
* _bt_readpage caller is required to call _bt_checkkeys against page's
* finaltup with forcenonrequired=false whenever we initially set
* forcenonrequired=true. That way the scan's arrays will reliably track
* its progress through the index's key space.
*
* We don't expect this when _bt_readpage caller has no finaltup due to
* its page being the rightmost (or the leftmost, during backwards scans).
* When we see that _bt_readpage has no finaltup, back out of everything.
*/
Assert(!pstate->forcenonrequired || so->numArrayKeys);
if (pstate->forcenonrequired && !pstate->finaltup)
{
pstate->forcenonrequired = false;
pstate->startikey = 0;
}And _bt_checkkeys() uses the offset when it starts comparing:
static bool
_bt_checkkeys(IndexScanDesc scan, BTReadPageState *pstate, bool arrayKeys,
IndexTuple tuple, int tupnatts)
{
TupleDesc tupdesc = RelationGetDescr(scan->indexRelation);
BTScanOpaque so PG_USED_FOR_ASSERTS_ONLY = (BTScanOpaque) scan->opaque;
ScanDirection dir = pstate->dir;
int ikey = pstate->startikey;
bool res;Compare that with the naive version, which would start at ikey = 0 and
evaluate every key against every tuple. With startikey, the prefix of
keys that are guaranteed satisfied by every tuple on the page is skipped
entirely.
The commit message explains the intent precisely:
Although this is independently useful work, its main goal is to avoid performance regressions with index scans that use skip arrays, but still never manage to skip over irrelevant leaf pages. We must avoid wasting CPU cycles on overly granular skip array maintenance in these cases. The new “startikey” optimization helps with this by selectively disabling array maintenance for the duration of a _bt_readpage call. This has no lasting consequences for the scan’s array keys (they’ll still reliably track the scan’s progress through the index’s key space whenever the scan is “between pages”).
And it ends with the sentence that is the best summary of the whole feature:
Many scans that cannot possibly skip will still benefit from the use of skip arrays, since they’ll allow the “startikey” optimization to be as effective as possible (by allowing preprocessing to mark all the scan’s keys as required). A scan that uses a skip array on “a” for a qual “WHERE a BETWEEN 0 AND 1_000_000 AND b = 42” is often much faster now, even when every tuple read by the scan has its own distinct “a” value.
The planner’s cost model
The planner does not generate a separate skip-scan path, but it does have
to price the scan, and the price depends on how many descents the skip
array will cause. That is btcostestimate() in
src/backend/utils/adt/selfuncs.c, and it is the only place in the
optimizer that knows about skip arrays.
The core variable is num_sa_scans — the estimated number of primitive
index scans. When the planner encounters a gap between the index columns
that have quals and the column that does, it multiplies in the skipped
column’s ndistinct:
/*
* Skip scans use skip arrays, which are ScalarArrayOp style
* arrays that generate their elements procedurally and on demand.
* Given a multi-column index on "(a, b)", and an SQL WHERE clause
* "WHERE b = 42", a skip scan will effectively use an indexqual
* "WHERE a = ANY('{every col a value}') AND b = 42". (Obviously,
* the array on "a" must also return "IS NULL" matches, since our
* WHERE clause used no strict operator on "a").
*/ /*
* When there's no inequality quals, account for the need to
* find an initial value by counting -inf/+inf as a value.
...
*/
if (indexSkipQuals == NIL)
ndistinct += 1;
/*
* Update num_sa_scans estimate by multiplying by ndistinct.
*
* We make the pessimistic assumption that there is no
* naturally occurring cross-column correlation. This is
* often wrong, but it seems best to err on the side of not
* expecting skipping to be helpful...
*/
num_sa_scans *= ndistinct;And then the discontinuity that keeps the estimate sane:
/*
* ...but back out of adding this latest group of 1 or more
* skip arrays when num_sa_scans exceeds the total number of
* index pages (revert to num_sa_scans from before indexcol).
* This causes a sharp discontinuity in cost (as a function of
* the indexcol's ndistinct), but that is representative of
* actual runtime costs.
*
* Note that skipping is helpful when each primitive index
* scan only manages to skip over 1 or 2 irrelevant leaf pages
* on average. Skip arrays bring savings in CPU costs due to
* the scan not needing to evaluate indexquals against every
* tuple, which can greatly exceed any savings in I/O costs.
* This test is a test of whether num_sa_scans implies that
* we're past the point where the ability to skip ceases to
* lower the scan's costs (even qual evaluation CPU costs).
*/
if (index->pages < num_sa_scans)
{
num_sa_scans = num_sa_scans_prev_cols;
break; /* done building indexBoundQuals */
}So: if the skipped column has so many distinct values that you would need one index descent per page of the index, the planner stops pretending skipping will help. The cost function has a cliff in it, and the comment says so.
There is a second clamp, applied to everything:
/*
* btree automatically combines individual array element primitive
* index scans whenever the tuples covered by the next set of array
* keys are close to tuples covered by the current set. That puts a
* natural ceiling on the worst case number of descents -- there
* cannot possibly be more than one descent per leaf page scanned.
*
* Clamp the number of descents to at most 1/3 the number of index
* pages. ...
*/
num_sa_scans = Min(num_sa_scans, ceil(index->pages * 0.3333333));
num_sa_scans = Max(num_sa_scans, 1);And the descent costs are charged per primitive scan, which is what makes a skip scan look expensive when it should not be used:
if (index->tuples > 1) /* avoid computing log(0)
{
descentCost = ceil(log(index->tuples) / log(2.0)) * cpu_operator_cost;
costs.indexStartupCost += descentCost;
costs.indexTotalCost += costs.num_sa_scans * descentCost;
}
Note the asymmetry in that code: the first descent is startup cost, the rest are total cost. Which is right, because the first descent is unavoidable — you have to find the first matching tuple somehow — while the rest are the cost of skipping.
Backwards scans
Skip scan works in both directions, and a separate optimization
(1bd4bc85cac, “Optimize nbtree backwards scans”) makes backwards
scans cheaper in general:
Make nbtree backwards scans optimistically access the next page to be read to the left by following a prevPage block number that’s now stashed in currPos when the leaf page is first read. This approach matches the one taken during forward scans, which follow a symmetric nextPage block number from currPos. We stash both a prevPage and a nextPage, since the scan direction might change (when fetching from a scrollable cursor).
Backwards scans will no longer need to lock the same page twice, except in rare cases where the scan detects a concurrent page split (or page deletion). Testing has shown this optimization to be particularly effective during parallel index-only backwards scans: ~12% reductions in query execution time are quite possible.
This matters for skip scan because a skip array in a backwards scan has
to step down through the values of the skipped column, using the
opclass’s decrement function and the SK_BT_PRIOR sentinel.
The regression test above covers exactly this: the same query with
ORDER BY t DESC, id DESC produces:
QUERY PLAN
-----------------------------------------------------------------
Index Only Scan Backward using btree_tall_idx on btree_tall_tbl
Index Cond: (id = 55)The caveats
The author of the feature is refreshingly candid about what does not
work. From the 8a510275dd6 commit message:
There are still some remaining regressions, affecting certain trickier cases.
Scans whose index quals have several range skip arrays, each on some high cardinality column, can still be slower than they were before the introduction of skip scan – even with the new “startikey” optimization. There are also known regressions affecting very selective index scans that use a skip array. The underlying issue with such selective scans is that they never get as far as reading a second leaf page, and so will never get a chance to consider applying the “startikey” optimization. In principle, all regressions could be avoided by teaching preprocessing to not add skip arrays whenever they aren’t expected to help, but it seems best to err on the side of robust performance.
So, in the author’s own list:
| Regression case | Why |
|---|---|
| Very selective scans that never reach a second leaf page | The startikey optimization only kicks in from the second page onwards, so the array maintenance cost is paid without the benefit |
| Several range skip arrays on high-cardinality columns | Each range skip array has to be maintained and checked; the savings do not materialize |
And the original commit 92fe23d9 is equally blunt about the
preprocessing rules being optimistic:
B-Tree preprocessing is optimistic about skipping working out: it applies static, generic rules when determining where to generate skip arrays, which assumes that the runtime overhead of maintaining skip arrays will pay for itself – or lead to only a modest performance loss. As things stand, these assumptions are much too optimistic: skip array maintenance will lead to unacceptable regressions with unsympathetic queries (queries whose scan can’t skip over many irrelevant leaf pages).
Other limitations worth knowing:
- Row comparisons stop the skip array machinery. From
_bt_num_array_keys():
/*
* Later preprocessing steps cannot merge a RowCompare into a skip
* array, so stop adding skip arrays once we see one. (Note that we
* can backfill skip arrays before a RowCompare, which will allow keys
* up to and including the RowCompare to be marked required.)
*
* Skip arrays work by maintaining a current array element value,
* which anchors lower-order keys via an implied equality constraint.
* This is incompatible with the current nbtree row comparison design,
* which compares all columns together, as an indivisible group.
*/The opclass needs an equality operator. If the leading column’s opclass has no
=operator in its opfamily, no skip array can be generated for it, and none for any later column either.Skip support is optional and type-dependent. Without it, the scan falls back on
SK_BT_NEXT/SK_BT_PRIORprobes, which are more expensive but still correct.The planner’s estimate is pessimistic about correlation. It assumes no natural cross-column correlation when multiplying in
ndistinct, “but it seems best to err on the side of not expecting skipping to be helpful”.A skip scan is not free even when it skips nothing. The
startikeyoptimization keeps the CPU cost down, but it does not make it zero, and for a scan that reads one page and stops, the extra page does not arrive.
What it all means in practice
A few conclusions:
- Skip scan changes what a multi-column index is worth. The
traditional advice — “a composite index on (a, b) is useless for
queries that only constrain
b” — is now only true whenbhas high cardinality relative to the number of index pages. The PostgreSQL documentation puts the threshold bluntly:
if
xhas no more than several hundred distinct values, skip scan will make searches for specificyvalues execute reasonably efficiently.
The feature is opportunistic, not planner-driven. The planner produces an ordinary index scan; the AM decides at runtime whether it can skip. There is no
Skip Scannode type inEXPLAIN, no competing path, and no plan flip depending on cardinality estimates at execution time.The interesting engineering is in the cost avoidance, not the feature.
startikeyexists so that adding a skip array to a scan that cannot benefit does not make it slower. The feature’s value is bounded by how cheaply the “cannot benefit” case can be handled.Discrete types get the good version. A
textleading column falls back on sentinel probes; anintleading column can guess the next value exactly. If you are designing a schema,int,date,oidandtimestampleading columns are the ones that skip scan well.It composes with the existing index machinery. Skip arrays are
SK_SEARCHARRAYscan keys withnum_elems = -1; they use the same array-advancement and primitive-scan scheduling that PostgreSQL 17 added forScalarArrayOpExprquals, and the sameSK_BT_REQFWD/SK_BT_REQBKWDprotocol. The feature is a preprocessing rule plus a new element source, not a new scan type.
Further reading
src/backend/access/nbtree/nbtpreprocesskeys.c— where skip arrays are generated. The header comment at the top of the file is the design document.src/backend/access/nbtree/nbtreadpage.c—_bt_set_startikey(),_bt_checkkeys(),_bt_advance_array_keys(),_bt_array_increment().src/backend/access/nbtree/nbtsearch.c—_bt_first()’s boundary-key logic for skip arrays.src/include/utils/skipsupport.h— the skip support interface, and the comment explaining when guessing the next value is profitable.src/backend/access/nbtree/nbtcompare.c— theint4skip support implementation.src/backend/utils/adt/selfuncs.c—btcostestimate(), the planner’s side of skip scan.doc/src/sgml/indices.sgml— the “Indexes on Multiple Columns” section, which describes the optimization from a user’s perspective.- Commits
92fe23d9(add skip scan),8a510275dd6(startikey optimization),21a152b37f3(primitive scan scheduling),1bd4bc85cac(backwards scans).