DEV Community

Cover image for Skip Scan vs. Loose Index Scan
Franck Pachot
Franck Pachot

Posted on

Skip Scan vs. Loose Index Scan

Both optimizations avoid (skip) reading (scan) irrelevant leaf pages by repositioning (seek) via a fresh index descent instead of scanning sequentially. This surface similarity explains why people often used "skip scan" and "loose index scan" interchangeably before the distinction was clearly defined. For example, I wrote YugabyteDB Skip Scan aka Loose Index Scan on compound index in 2022 because the two concepts were not clearly distinguished in the PostgreSQL wiki at that time. PostgreSQL implemented neither of those features yet, and YugabyteDB implemented both with the same hybrid scan mechanism. However, today the distinction matters because PostgreSQL 18 has added Skip Scan but not Loose Index Scan.

What they have in common: repositioning an index scan between multiple sub-ranges

A plain range scan seeks once to the start of a range and then reads forward or backward until past the end. Both Skip Scan and Loose Index Scan can instead reposition to a new range, or "grouping" (a run of index entries sharing the same leading-column value), via a new index descent, rather than reading through a single range sequentially. This avoids reading irrelevant leaf pages.

What's different: what happens within each sub-range

Skip Scan avoids reading leaf pages entirely outside a relevant sub-range. PostgreSQL does not skip index entries that might match: within a grouping, it still examines the relevant entries and returns every match, exactly as a normal scan would within that sub-range. Separately, PostgreSQL can sometimes avoid rechecking a scan key on a page whose high key proves that all entries satisfy it (as a CPU optimization), but it does not avoid reading the page.

A Loose Index Scan, by contrast, deliberately reads only the first entry of each range and then jumps to the next range — it never reads the remaining matching rows because it isn't trying to satisfy a later-column predicate, only to enumerate the distinct values of a prefix.

In short:

Skip Scan Loose Index Scan
Skips Leaf pages belonging to sub-ranges that can't satisfy the later-column predicate Leaf pages/entries belonging to any range after its first entry
Within a matched range Reads/checks every entry against the later-column Reads one entry, then repositions to the next range
Purpose of the leading column Vehicle for enumerating candidate ranges so a later-column predicate can be applied efficiently The thing being enumerated (e.g., DISTINCT) — no later-column predicate needed

They benefit from similar data distributions

Both optimizations benefit when the leading index column has relatively few distinct values and many rows per value. Repeated descents are then cheaper than scanning large groups of entries sequentially.

Skip Scan may be rejected when the number of descents exceeds the cost of scanning the index normally. A Loose Index Scan has the same trade-off: if almost every row has a different prefix value, one descent per value is not worthwhile. Its advantage appears when each group contains enough duplicate entries to make skipping the rest of the group profitable.

The distinction is therefore not primarily the index definition or the data distribution. It is the query's objective: Skip Scan must return all matching rows, whereas Loose Index Scan deliberately returns only one representative per group.

Example

I created a table with an index on two columns:

CREATE EXTENSION IF NOT EXISTS pageinspect;

DROP TABLE IF EXISTS demo CASCADE;

CREATE TABLE demo (
    a       integer NOT NULL,
    b       text    NOT NULL,
    payload text    NOT NULL
);

INSERT INTO demo (a, b, payload)
SELECT
    a,
    repeat(md5(g::text), 28),       -- approximately 900 bytes
    repeat('payload-' || a || '-' || g, 20)
FROM generate_series(1, 8) AS a
CROSS JOIN generate_series(1, 80) AS g;

CREATE INDEX demo_ab_idx ON demo (a, b);

VACUUM ANALYZE demo;
Enter fullscreen mode Exit fullscreen mode

Here is a query that shows the index entries in their logical order:

SELECT
    s.blkno AS index_block,
    i.itemoffset,
    d.a,
    left(d.b, 100) || '...' AS b,
    i.itemlen,
    i.htid,
    i.data as data 
FROM bt_multi_page_stats('demo_ab_idx', 1, -1) AS s
CROSS JOIN LATERAL bt_page_items('demo_ab_idx', s.blkno) AS i
JOIN demo AS d
  ON d.ctid = i.htid
WHERE s.type = 'l'
ORDER BY
    d.a,
    d.b;
Enter fullscreen mode Exit fullscreen mode

My example has 640 rows:

A Skip Scan will scan all values of "a" but may skip the portions of each "a" grouping outside the "b" range, for example WHERE b LIKE '28%':

 index_block | itemoffset | a |                                                    b                                                    | itemlen |  htid   |                                                                                              data
-------------+------------+---+---------------------------------------------------------------------------------------------------------+---------+---------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
           1 |         14 | 1 | 2838023a778dfaecdc212708f721b7882838023a778dfaecdc212708f721b7882838023a778dfaecdc212708f721b7882838... |      72 | (8,2)   | 01 00 00 00 da 00 00 00 80 03 00 40 ff 11 32 38 33 38 30 32 33 61 37 37 38 64 66 61 65 63 64 63 32 31 32 37 30 38 66 37 32 31 62 37 38 38 20 00 ff ff ff 4b 50 31 62 37 38 38 00 00 00 00 00 00
           1 |         15 | 1 | 28dd2c7955ce926456240b2ff0100bde28dd2c7955ce926456240b2ff0100bde28dd2c7955ce926456240b2ff0100bde28dd... |      72 | (12,4)  | 01 00 00 00 da 00 00 00 80 03 00 40 ff 11 32 38 64 64 32 63 37 39 35 35 63 65 39 32 36 34 35 36 32 34 30 62 32 66 66 30 31 30 30 62 64 65 20 00 ff ff ff 4b 50 30 30 62 64 65 00 00 00 00 00 00
           1 |         94 | 2 | 2838023a778dfaecdc212708f721b7882838023a778dfaecdc212708f721b7882838023a778dfaecdc212708f721b7882838... |      72 | (21,3)  | 02 00 00 00 da 00 00 00 80 03 00 40 ff 11 32 38 33 38 30 32 33 61 37 37 38 64 66 61 65 63 64 63 32 31 32 37 30 38 66 37 32 31 62 37 38 38 20 00 ff ff ff 4b 50 31 62 37 38 38 00 00 00 00 00 00
           1 |         95 | 2 | 28dd2c7955ce926456240b2ff0100bde28dd2c7955ce926456240b2ff0100bde28dd2c7955ce926456240b2ff0100bde28dd... |      72 | (25,5)  | 02 00 00 00 da 00 00 00 80 03 00 40 ff 11 32 38 64 64 32 63 37 39 35 35 63 65 39 32 36 34 35 36 32 34 30 62 32 66 66 30 31 30 30 62 64 65 20 00 ff ff ff 4b 50 30 30 62 64 65 00 00 00 00 00 00
           2 |         78 | 3 | 2838023a778dfaecdc212708f721b7882838023a778dfaecdc212708f721b7882838023a778dfaecdc212708f721b7882838... |      72 | (34,3)  | 03 00 00 00 da 00 00 00 80 03 00 40 ff 11 32 38 33 38 30 32 33 61 37 37 38 64 66 61 65 63 64 63 32 31 32 37 30 38 66 37 32 31 62 37 38 38 20 00 ff ff ff 4b 50 31 62 37 38 38 00 00 00 00 00 00
           2 |         79 | 3 | 28dd2c7955ce926456240b2ff0100bde28dd2c7955ce926456240b2ff0100bde28dd2c7955ce926456240b2ff0100bde28dd... |      72 | (38,5)  | 03 00 00 00 da 00 00 00 80 03 00 40 ff 11 32 38 64 64 32 63 37 39 35 35 63 65 39 32 36 34 35 36 32 34 30 62 32 66 66 30 31 30 30 62 64 65 20 00 ff ff ff 4b 50 30 30 62 64 65 00 00 00 00 00 00
           4 |         62 | 4 | 2838023a778dfaecdc212708f721b7882838023a778dfaecdc212708f721b7882838023a778dfaecdc212708f721b7882838... |      72 | (47,3)  | 04 00 00 00 da 00 00 00 80 03 00 40 ff 11 32 38 33 38 30 32 33 61 37 37 38 64 66 61 65 63 64 63 32 31 32 37 30 38 66 37 32 31 62 37 38 38 20 00 ff ff ff 4b 50 31 62 37 38 38 00 00 00 00 00 00
           4 |         63 | 4 | 28dd2c7955ce926456240b2ff0100bde28dd2c7955ce926456240b2ff0100bde28dd2c7955ce926456240b2ff0100bde28dd... |      72 | (51,5)  | 04 00 00 00 da 00 00 00 80 03 00 40 ff 11 32 38 64 64 32 63 37 39 35 35 63 65 39 32 36 34 35 36 32 34 30 62 32 66 66 30 31 30 30 62 64 65 20 00 ff ff ff 4b 50 30 30 62 64 65 00 00 00 00 00 00
           5 |         46 | 5 | 2838023a778dfaecdc212708f721b7882838023a778dfaecdc212708f721b7882838023a778dfaecdc212708f721b7882838... |      72 | (60,3)  | 05 00 00 00 da 00 00 00 80 03 00 40 ff 11 32 38 33 38 30 32 33 61 37 37 38 64 66 61 65 63 64 63 32 31 32 37 30 38 66 37 32 31 62 37 38 38 20 00 ff ff ff 4b 50 31 62 37 38 38 00 00 00 00 00 00
           5 |         47 | 5 | 28dd2c7955ce926456240b2ff0100bde28dd2c7955ce926456240b2ff0100bde28dd2c7955ce926456240b2ff0100bde28dd... |      72 | (64,5)  | 05 00 00 00 da 00 00 00 80 03 00 40 ff 11 32 38 64 64 32 63 37 39 35 35 63 65 39 32 36 34 35 36 32 34 30 62 32 66 66 30 31 30 30 62 64 65 20 00 ff ff ff 4b 50 30 30 62 64 65 00 00 00 00 00 00
           6 |         30 | 6 | 2838023a778dfaecdc212708f721b7882838023a778dfaecdc212708f721b7882838023a778dfaecdc212708f721b7882838... |      72 | (73,3)  | 06 00 00 00 da 00 00 00 80 03 00 40 ff 11 32 38 33 38 30 32 33 61 37 37 38 64 66 61 65 63 64 63 32 31 32 37 30 38 66 37 32 31 62 37 38 38 20 00 ff ff ff 4b 50 31 62 37 38 38 00 00 00 00 00 00
           6 |         31 | 6 | 28dd2c7955ce926456240b2ff0100bde28dd2c7955ce926456240b2ff0100bde28dd2c7955ce926456240b2ff0100bde28dd... |      72 | (77,5)  | 06 00 00 00 da 00 00 00 80 03 00 40 ff 11 32 38 64 64 32 63 37 39 35 35 63 65 39 32 36 34 35 36 32 34 30 62 32 66 66 30 31 30 30 62 64 65 20 00 ff ff ff 4b 50 30 30 62 64 65 00 00 00 00 00 00
           7 |         14 | 7 | 2838023a778dfaecdc212708f721b7882838023a778dfaecdc212708f721b7882838023a778dfaecdc212708f721b7882838... |      72 | (86,3)  | 07 00 00 00 da 00 00 00 80 03 00 40 ff 11 32 38 33 38 30 32 33 61 37 37 38 64 66 61 65 63 64 63 32 31 32 37 30 38 66 37 32 31 62 37 38 38 20 00 ff ff ff 4b 50 31 62 37 38 38 00 00 00 00 00 00
           7 |         15 | 7 | 28dd2c7955ce926456240b2ff0100bde28dd2c7955ce926456240b2ff0100bde28dd2c7955ce926456240b2ff0100bde28dd... |      72 | (90,5)  | 07 00 00 00 da 00 00 00 80 03 00 40 ff 11 32 38 64 64 32 63 37 39 35 35 63 65 39 32 36 34 35 36 32 34 30 62 32 66 66 30 31 30 30 62 64 65 20 00 ff ff ff 4b 50 30 30 62 64 65 00 00 00 00 00 00
           7 |         94 | 8 | 2838023a778dfaecdc212708f721b7882838023a778dfaecdc212708f721b7882838023a778dfaecdc212708f721b7882838... |      72 | (99,3)  | 08 00 00 00 da 00 00 00 80 03 00 40 ff 11 32 38 33 38 30 32 33 61 37 37 38 64 66 61 65 63 64 63 32 31 32 37 30 38 66 37 32 31 62 37 38 38 20 00 ff ff ff 4b 50 31 62 37 38 38 00 00 00 00 00 00
           7 |         95 | 8 | 28dd2c7955ce926456240b2ff0100bde28dd2c7955ce926456240b2ff0100bde28dd2c7955ce926456240b2ff0100bde28dd... |      72 | (103,5) | 08 00 00 00 da 00 00 00 80 03 00 40 ff 11 32 38 64 64 32 63 37 39 35 35 63 65 39 32 36 34 35 36 32 34 30 62 32 66 66 30 31 30 30 62 64 65 20 00 ff ff ff 4b 50 30 30 62 64 65 00 00 00 00 00 00
Enter fullscreen mode Exit fullscreen mode

A Loose Index Scan will read only the first row for each value of "a", for example, in SELECT DISTINCT ON (a):

 index_block | itemoffset | a |                                                    b                                                    | itemlen |  htid  |                                                                                              data
-------------+------------+---+---------------------------------------------------------------------------------------------------------+---------+--------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
           1 |          2 | 1 | 02e74f10e0327ad868d138f2b4fdd6f002e74f10e0327ad868d138f2b4fdd6f002e74f10e0327ad868d138f2b4fdd6f002e7... |      72 | (4,2)  | 01 00 00 00 da 00 00 00 80 03 00 40 ff 11 30 32 65 37 34 66 31 30 65 30 33 32 37 61 64 38 36 38 64 31 33 38 66 32 62 34 66 64 64 36 66 30 20 00 ff ff ff 4b 50 64 64 36 66 30 00 00 00 00 00 00
           1 |         82 | 2 | 02e74f10e0327ad868d138f2b4fdd6f002e74f10e0327ad868d138f2b4fdd6f002e74f10e0327ad868d138f2b4fdd6f002e7... |      72 | (17,3) | 02 00 00 00 da 00 00 00 80 03 00 40 ff 11 30 32 65 37 34 66 31 30 65 30 33 32 37 61 64 38 36 38 64 31 33 38 66 32 62 34 66 64 64 36 66 30 20 00 ff ff ff 4b 50 64 64 36 66 30 00 00 00 00 00 00
           2 |         66 | 3 | 02e74f10e0327ad868d138f2b4fdd6f002e74f10e0327ad868d138f2b4fdd6f002e74f10e0327ad868d138f2b4fdd6f002e7... |      72 | (30,3) | 03 00 00 00 da 00 00 00 80 03 00 40 ff 11 30 32 65 37 34 66 31 30 65 30 33 32 37 61 64 38 36 38 64 31 33 38 66 32 62 34 66 64 64 36 66 30 20 00 ff ff ff 4b 50 64 64 36 66 30 00 00 00 00 00 00
           4 |         50 | 4 | 02e74f10e0327ad868d138f2b4fdd6f002e74f10e0327ad868d138f2b4fdd6f002e74f10e0327ad868d138f2b4fdd6f002e7... |      72 | (43,3) | 04 00 00 00 da 00 00 00 80 03 00 40 ff 11 30 32 65 37 34 66 31 30 65 30 33 32 37 61 64 38 36 38 64 31 33 38 66 32 62 34 66 64 64 36 66 30 20 00 ff ff ff 4b 50 64 64 36 66 30 00 00 00 00 00 00
           5 |         34 | 5 | 02e74f10e0327ad868d138f2b4fdd6f002e74f10e0327ad868d138f2b4fdd6f002e74f10e0327ad868d138f2b4fdd6f002e7... |      72 | (56,3) | 05 00 00 00 da 00 00 00 80 03 00 40 ff 11 30 32 65 37 34 66 31 30 65 30 33 32 37 61 64 38 36 38 64 31 33 38 66 32 62 34 66 64 64 36 66 30 20 00 ff ff ff 4b 50 64 64 36 66 30 00 00 00 00 00 00
           6 |         18 | 6 | 02e74f10e0327ad868d138f2b4fdd6f002e74f10e0327ad868d138f2b4fdd6f002e74f10e0327ad868d138f2b4fdd6f002e7... |      72 | (69,3) | 06 00 00 00 da 00 00 00 80 03 00 40 ff 11 30 32 65 37 34 66 31 30 65 30 33 32 37 61 64 38 36 38 64 31 33 38 66 32 62 34 66 64 64 36 66 30 20 00 ff ff ff 4b 50 64 64 36 66 30 00 00 00 00 00 00
           7 |          2 | 7 | 02e74f10e0327ad868d138f2b4fdd6f002e74f10e0327ad868d138f2b4fdd6f002e74f10e0327ad868d138f2b4fdd6f002e7... |      72 | (82,3) | 07 00 00 00 da 00 00 00 80 03 00 40 ff 11 30 32 65 37 34 66 31 30 65 30 33 32 37 61 64 38 36 38 64 31 33 38 66 32 62 34 66 64 64 36 66 30 20 00 ff ff ff 4b 50 64 64 36 66 30 00 00 00 00 00 00
           7 |         82 | 8 | 02e74f10e0327ad868d138f2b4fdd6f002e74f10e0327ad868d138f2b4fdd6f002e74f10e0327ad868d138f2b4fdd6f002e7... |      72 | (95,3) | 08 00 00 00 da 00 00 00 80 03 00 40 ff 11 30 32 65 37 34 66 31 30 65 30 33 32 37 61 64 38 36 38 64 31 33 38 66 32 62 34 66 64 64 36 66 30 20 00 ff ff ff 4b 50 64 64 36 66 30 00 00 00 00 00 00
Enter fullscreen mode Exit fullscreen mode

Here are some examples with the execution plans.

Skip Scan is visible in PostgreSQL 18. This query returns 16 rows from five of the eight a groups. PostgreSQL reports five Index Searches and 13 buffer hits:

explain (analyze, buffers)
SELECT a,b FROM demo WHERE b LIKE '28%';
                                                          QUERY PLAN
-------------------------------------------------------------------------------------------------------------------------------
 Index Only Scan using demo_ab_idx on demo  (cost=0.28..13.17 rows=16 width=904) (actual time=0.031..0.087 rows=16.00 loops=1)
   Index Cond: ((b >= '28'::text) AND (b < '29'::text))
   Filter: (b ~~ '28%'::text)
   Heap Fetches: 0
   Index Searches: 5
   Buffers: shared hit=13
 Planning Time: 0.101 ms
 Execution Time: 0.101 ms
(8 rows)

Enter fullscreen mode Exit fullscreen mode

The five searches do not mean that PostgreSQL blindly performs one fresh descent for every possible value of a. The executor can continue through nearby leaf pages when that is cheaper. It performs a new descent when repositioning is estimated to save work. The buffer count is cumulative, so pages reused by multiple searches can contribute multiple buffer hits.

PostgreSQL 18 does not implement Loose Index Scan natively. DISTINCT ON therefore performs one ordinary index scan, reads all 640 index entries, and then lets Unique retain the first row from each of the eight groups. The scan reports one Index Search and nine buffer hits:

postgres=# explain (analyze, buffers)
SELECT DISTINCT ON (a) b FROM demo
;
                                                              QUERY PLAN
---------------------------------------------------------------------------------------------------------------------------------------
 Unique  (cost=0.28..43.48 rows=8 width=904) (actual time=0.017..0.135 rows=8.00 loops=1)
   Buffers: shared hit=9
   ->  Index Only Scan using demo_ab_idx on demo  (cost=0.28..41.88 rows=640 width=904) (actual time=0.016..0.097 rows=640.00 loops=1)
         Heap Fetches: 0
         Index Searches: 1
         Buffers: shared hit=9
 Planning Time: 0.055 ms
 Execution Time: 0.150 ms
(8 rows)

Enter fullscreen mode Exit fullscreen mode

We can simulate a Loose Index Scan with a recursive WITH clause that reads a single row from each scan with LIMIT 1:

postgres=# explain (analyze, buffers)
WITH RECURSIVE first_per_a AS (
    (
        SELECT a, b
        FROM demo
        ORDER BY a, b
        LIMIT 1
    )
    UNION ALL
    SELECT nxt.a, nxt.b
    FROM first_per_a cur
    CROSS JOIN LATERAL (
        SELECT a, b
        FROM demo
        WHERE a > cur.a
        ORDER BY a, b
        LIMIT 1
    ) AS nxt
)
SELECT b
FROM first_per_a
ORDER BY a;
                                                                           QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------------------------------------------
 Sort  (cost=44.62..44.87 rows=101 width=36) (actual time=0.068..0.069 rows=8.00 loops=1)
   Sort Key: first_per_a.a
   Sort Method: quicksort  Memory: 25kB
   Buffers: shared hit=21
   CTE first_per_a
     ->  Recursive Union  (cost=0.28..39.23 rows=101 width=904) (actual time=0.018..0.058 rows=8.00 loops=1)
           Storage: Memory  Maximum Storage: 33kB
           Buffers: shared hit=21
           ->  Limit  (cost=0.28..0.34 rows=1 width=904) (actual time=0.017..0.018 rows=1.00 loops=1)
                 Buffers: shared hit=3
                 ->  Index Only Scan using demo_ab_idx on demo  (cost=0.28..41.88 rows=640 width=904) (actual time=0.016..0.017 rows=1.00 loops=1)
                       Heap Fetches: 0
                       Index Searches: 1
                       Buffers: shared hit=3
           ->  Nested Loop  (cost=0.28..3.79 rows=10 width=904) (actual time=0.004..0.005 rows=0.88 loops=8)
                 Buffers: shared hit=18
                 ->  WorkTable Scan on first_per_a cur  (cost=0.00..0.20 rows=10 width=4) (actual time=0.000..0.000 rows=1.00 loops=8)
                 ->  Limit  (cost=0.28..0.35 rows=1 width=904) (actual time=0.004..0.004 rows=0.88 loops=8)
                       Buffers: shared hit=18
                       ->  Index Only Scan using demo_ab_idx on demo demo_1  (cost=0.28..16.00 rows=213 width=904) (actual time=0.003..0.003 rows=0.88 loops=8)
                             Index Cond: (a > cur.a)
                             Heap Fetches: 0
                             Index Searches: 8
                             Buffers: shared hit=18
   ->  CTE Scan on first_per_a  (cost=0.00..2.02 rows=101 width=36) (actual time=0.021..0.062 rows=8.00 loops=1)
         Storage: Memory  Maximum Storage: 17kB
         Buffers: shared hit=21
 Planning Time: 0.133 ms
 Execution Time: 0.093 ms
(29 rows)

Enter fullscreen mode Exit fullscreen mode

As with Skip Scan, we see multiple Index Searches under loop=8. Note that while actual time and rows are the average per loop, other statistics like Buffers, Index Searches, and Heap Fetches are cumulative across all loops. One initial index search finds the first group, followed by eight executions of the recursive subplan. Seven of those executions find the next value of a. The eighth searches for a > 8, finds nothing, and terminates the recursion. Thus, the recursive part has loops=8, returns an average of 0.88 rows per loop (7 rows in total), performs 8 total index searches, and accounts for 18 cumulative buffer hits.

While PostgreSQL 18 introduced the Index Searches display for Index Skip Scan, this is not an index skip scan. It is just the metric incremented over loops, and the number of buffers read is the same as in previous versions.

On my tiny 640-row example, the recursive CTE reads more buffers than the full scan because eight groups of eighty rows are too shallow to amortize the repeated descents. However, it scales with the number of groups rather than with the number of rows in those groups. With much larger groups, the ordinary scan grows with the total number of rows, while the loose-scan emulation still performs roughly one probe per group, plus the final probe that discovers the end.

Who implements what

Here is a quick comparison of database engines and the terminology they use. MySQL is the only mainstream engine that ships both under two clearly separate names:

Engine Loose Index Scan Skip Scan
PostgreSQL ≤ 17 ❌ (recursive CTE workaround)
PostgreSQL 18 ❌ (recursive CTE workaround)
MongoDB ✅ — DISTINCT_SCAN ❌ ($in with max 200 values workaround)
MySQL ✅ — Loose Index Scan (GROUP BY/DISTINCT/MIN/MAX) ✅ — Skip Scan Range Access
MariaDB ✅ — LooseScan (semi-join duplicate elimination)
Oracle Database ✅ — Index Skip Scan
Db2 ✅ — index skip scan / jump scan
SQLite ✅ — skip-scan
CockroachDB ✅ , with limitations
YugabyteDB ✅ — Hybrid Scan
SQL Server

Even if implementations may be similar, the result sets make the distinction clear:

  • Skip Scan returns all matching rows after probing a leading-key range that was initially omitted
  • Loose Index Scan returns one row per distinct prefix value

Conclusion

Skip Scan and Loose Index Scan traverse the same B-Tree with the same physical move — abandon the sequential leaf walk, descend again, land further ahead — but they answer different questions, and that is the whole distinction:

  • Skip Scan asks: "which rows match my predicate on a later index column?" It returns every matching row. The leading column is a vehicle: it is enumerated only so the real predicate can be applied within each of its ranges.
  • Loose Index Scan asks: "what are the distinct values of this index prefix?" It returns one entry per value. The leading column is the answer itself, and everything after the first entry of each range is dead weight.

Both benefit when the leading column has few distinct values and many rows per value. That makes repositioning cheaper than reading every duplicate entry sequentially. The difference lies not in the preferred data distribution, but in what the query must return.

That symmetry is also why PostgreSQL 18 is easy to misread. Skip Scan is genuinely there, Index Searches makes it visible, and it is a real improvement for predicates on non-leading columns. But it does nothing for SELECT DISTINCT, because it never stops early — it reads and checks every entry in each range it visits.

The name in the documentation is not enough. Look at the result and the access pattern: if the scan returns every qualifying row after probing multiple prefix groups, it exhibits skip-scan behavior. If it returns one representative per distinct prefix value and skips duplicates, it exhibits loose-scan behavior.

Top comments (0)