critwire · Learn · Learn's data

Indexes and query plans d3

Prerequisites: Keys and constraints (d1)

Find out why a query reads the whole table, and what makes it read only the rows it needs: B-tree, GiST and BRIN indexes, plans from EXPLAIN ANALYZE, and the statistics the planner chooses by. Everything runs in your own PostgreSQL, on the lab tables (the setup page).

Concept

PostgreSQL stores a table as 8 kB pages, about fifty records to a page in occurrences. A query that has nothing better reads every page, in order, and tests every row: a sequential scan. An index is a second structure, kept in step with the table, that leads from a value to the rows holding it. Before running a query, the planner writes several plans and picks the one it expects to read the fewest pages for the least work. That expectation comes from the statistics ANALYZE collects. So "why is this a sequential scan?" has four usual answers: there is no index that fits; the query hides the column inside an expression; the filter keeps so many rows that reading the whole table is cheaper; or the statistics are wrong.

The three ways a plan reads the table, on a small one of twelve pages where six hold the nine rows a query wants:

A Seq Scan reads every page; an Index Scan fetches row by row; a Bitmap Heap Scan reads each matching page once

Each lab below starts from the loaded tables: critwire's tests run every block in a transaction and roll it back. To do the same in psql, type BEGIN; before a lab, run the blocks named under its Runs after first, and type ROLLBACK; after it, so the indexes you make in one lab don't change the plans of the next. The download does this for you.

Worked example

Why is this a sequential scan? First, statistics everyone can reproduce. ANALYZE reads a random sample, so its numbers could differ slightly from one run to the next; with the target set to 1,000 it reads 300,000 rows, more than occurrences holds, so it reads them all and gets the same statistics every time. SET LOCAL lasts until the transaction ends, and ROLLBACK undoes the ANALYZE too, so the blocks that read these statistics run after this one:

Checked 2026-11-02 on PostgreSQL 16.15, PostGIS 3.4.2, GEOS 3.12.1, GDAL 3.8.4.

d3-analyze · PostgreSQL with PostGIS

SET LOCAL default_statistics_target = 1000;
ANALYZE occurrences;
SELECT c.reltuples::bigint AS planner_rows, (SELECT count(*) FROM occurrences) AS table_rows
FROM pg_class AS c
WHERE c.oid = 'occurrences'::regclass;
-- result: d3-analyze · 1 row · sha256 2415e801456a
planner_rows | table_rows
40395 | 40395

In d3.sql, with the blocks it runs after.

Temminck's pangolin (species 63) has nine records in the lab. With no index on species_id, PostgreSQL reads all 40,395 rows to find them:

d3-seq · PostgreSQL with PostGIS

Runs after: d3-analyze

EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT id, year, month
FROM occurrences
WHERE species_id = 63;

What the plan must show:

An example plan, from the versions above; yours passes if it shows the lines listed, whatever else differs.

-- result: d3-seq · 3 rows · sha256 2b6ae5eab0fd
QUERY PLAN
Seq Scan on occurrences (actual rows=9 loops=1)
  Filter: (species_id = 63)
  Rows Removed by Filter: 40386

In d3.sql, with the blocks it runs after.

Rows Removed by Filter is the waste: every row tested and thrown away. An index on the foreign key d1 left unindexed:

d3-btree · PostgreSQL with PostGIS

Runs after: d3-analyze

CREATE INDEX occurrences_species_id_idx ON occurrences (species_id);
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT id, year, month
FROM occurrences
WHERE species_id = 63;

What the plan must show:

An example plan, from the versions above; yours passes if it shows the lines listed, whatever else differs.

-- result: d3-btree · 5 rows · sha256 d6791edbc017
QUERY PLAN
Bitmap Heap Scan on occurrences (actual rows=9 loops=1)
  Recheck Cond: (species_id = 63)
  Heap Blocks: exact=6
  ->  Bitmap Index Scan on occurrences_species_id_idx (actua…
        Index Cond: (species_id = 63)

In d3.sql, with the blocks it runs after.

The index finds the nine rows, and the table is read only where they are: six pages instead of all of them.

Lab

  1. An index the planner doesn't use. With an index on year, ask for every record since 2000:

    d3-many-rows · PostgreSQL with PostGIS

    Runs after: d3-analyze

    CREATE INDEX occurrences_year_idx ON occurrences (year);
    EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
    SELECT id, species_id
    FROM occurrences
    WHERE year >= 2000;

    What the plan must show:

    • the top node returns 29261 rows (actual rows=29261)
    • a line with Seq Scan on occurrences
    • no line with occurrences_year_idx

    An example plan, from the versions above; yours passes if it shows the lines listed, whatever else differs.

    -- result: d3-many-rows · 3 rows · sha256 e99d8d0080da
    QUERY PLAN
    Seq Scan on occurrences (actual rows=29261 loops=1)
      Filter: (year >= 2000)
      Rows Removed by Filter: 11134

    In d3.sql, with the blocks it runs after.

  2. Seven records in ten are from 2000 or later. Through the index, PostgreSQL would read much of the index and still nearly every page of the table, so reading the table straight through is cheaper. Ask for the records of 2026, 74 of them, and the same index is used:

    d3-few-rows · PostgreSQL with PostGIS

    Runs after: d3-analyze, d3-many-rows

    EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
    SELECT id, species_id
    FROM occurrences
    WHERE year >= 2026;

    What the plan must show:

    • the top node returns 74 rows (actual rows=74)
    • a line with occurrences_year_idx
    • a line with Index Cond: (year >= 2026)
    • no line with Seq Scan

    An example plan, from the versions above; yours passes if it shows the lines listed, whatever else differs.

    -- result: d3-few-rows · 2 rows · sha256 38ac5a7e9de0
    QUERY PLAN
    Index Scan using occurrences_year_idx on occurrences (actual…
      Index Cond: (year >= 2026)

    In d3.sql, with the blocks it runs after.

  3. A column inside an expression. The records of the 1850s, written as "the decade is 185":

    d3-function · PostgreSQL with PostGIS

    Runs after: d3-analyze, d3-many-rows

    EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
    SELECT id, species_id
    FROM occurrences
    WHERE year / 10 = 185;

    What the plan must show:

    • the top node returns 26 rows (actual rows=26)
    • a line with Seq Scan on occurrences
    • a line with Filter: ((year / 10) = 185)
    • no line with occurrences_year_idx

    An example plan, from the versions above; yours passes if it shows the lines listed, whatever else differs.

    -- result: d3-function · 3 rows · sha256 1f4188e01d9f
    QUERY PLAN
    Seq Scan on occurrences (actual rows=26 loops=1)
      Filter: ((year / 10) = 185)
      Rows Removed by Filter: 40369

    In d3.sql, with the blocks it runs after.

  4. The index holds year, not year / 10, so it can't help. The same question as a range on the bare column can use it:

    d3-range · PostgreSQL with PostGIS

    Runs after: d3-analyze, d3-many-rows

    EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
    SELECT id, species_id
    FROM occurrences
    WHERE year BETWEEN 1850 AND 1859;

    What the plan must show:

    • the top node returns 26 rows (actual rows=26)
    • a line with occurrences_year_idx
    • a line with Index Cond: ((year >= 1850) AND (year <= 1859))
    • no line with Seq Scan

    An example plan, from the versions above; yours passes if it shows the lines listed, whatever else differs.

    -- result: d3-range · 2 rows · sha256 d4f341dad05f
    QUERY PLAN
    Index Scan using occurrences_year_idx on occurrences (actual…
      Index Cond: ((year >= 1850) AND (year <= 1859))

    In d3.sql, with the blocks it runs after.

    The other way out is an index on the expression itself, CREATE INDEX ON occurrences ((year / 10)), which the planner uses for exactly that expression (run ANALYZE afterwards, so it has statistics for it too). The same goes for lower(name) = … and an index on lower(name).

  5. A join through the foreign key, as an app asks for one animal's records by its slug. Without an index on occurrences.species_id, PostgreSQL builds a hash table of the matching animal and runs every record past it:

    d3-join-seq · PostgreSQL with PostGIS

    Runs after: d3-analyze

    EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
    SELECT o.id, o.year
    FROM species AS s
    JOIN occurrences AS o ON o.species_id = s.id
    WHERE s.slug = 'temmincks-pangolin';

    What the plan must show:

    • the top node returns 9 rows (actual rows=9)
    • a line with Hash Join
    • a line with Seq Scan on occurrences o (actual rows=40395

    An example plan, from the versions above; yours passes if it shows the lines listed, whatever else differs.

    -- result: d3-join-seq · 8 rows · sha256 5f5262a5b6e9
    QUERY PLAN
    Hash Join (actual rows=9 loops=1)
      Hash Cond: (o.species_id = s.id)
      ->  Seq Scan on occurrences o (actual rows=40395 loops=1)
      ->  Hash (actual rows=1 loops=1)
            Buckets: 1024  Batches: 1  Memory Usage: 9kB
    … 3 more rows

    In d3.sql, with the blocks it runs after.

  6. With the index from the worked example, it finds the animal first, then looks up its records (a nested loop: for each row outside, an index lookup inside). species is read in full both times: 76 rows fit in a few pages, and no index beats that.

    d3-join-index · PostgreSQL with PostGIS

    Runs after: d3-analyze, d3-btree

    EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
    SELECT o.id, o.year
    FROM species AS s
    JOIN occurrences AS o ON o.species_id = s.id
    WHERE s.slug = 'temmincks-pangolin';

    What the plan must show:

    • the top node returns 9 rows (actual rows=9)
    • a line with Nested Loop
    • a line with Index Cond: (species_id = s.id)
    • no line with Seq Scan on occurrences

    An example plan, from the versions above; yours passes if it shows the lines listed, whatever else differs.

    -- result: d3-join-index · 9 rows · sha256 65566407e202
    QUERY PLAN
    Nested Loop (actual rows=9 loops=1)
      ->  Seq Scan on species s (actual rows=1 loops=1)
            Filter: (slug = 'temmincks-pangolin'::text)
            Rows Removed by Filter: 75
      ->  Bitmap Heap Scan on occurrences o (actual rows=9 loops…
    … 4 more rows

    In d3.sql, with the blocks it runs after.

  7. An index on two columns. The devil's records of 2010, found through both columns at once:

    d3-two-columns · PostgreSQL with PostGIS

    Runs after: d3-analyze

    CREATE INDEX occurrences_species_year_idx ON occurrences (species_id, year);
    EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
    SELECT id, month
    FROM occurrences
    WHERE species_id = 48 AND year = 2010;

    What the plan must show:

    • the top node returns 182 rows (actual rows=182)
    • a line with occurrences_species_year_idx
    • a line with Index Cond: ((species_id = 48) AND (year = 2010))
    • no line with Seq Scan

    An example plan, from the versions above; yours passes if it shows the lines listed, whatever else differs.

    -- result: d3-two-columns · 5 rows · sha256 e46d89a1ff97
    QUERY PLAN
    Bitmap Heap Scan on occurrences (actual rows=182 loops=1)
      Recheck Cond: ((species_id = 48) AND (year = 2010))
      Heap Blocks: exact=18
      ->  Bitmap Index Scan on occurrences_species_year_idx (act…
            Index Cond: ((species_id = 48) AND (year = 2010))

    In d3.sql, with the blocks it runs after.

  8. The same index, asked about year alone (1850 to 1899). It is still used, which surprises people taught that a second column is useless on its own. But the years are sorted only inside each animal, so there is no place to jump to: PostgreSQL 16 reads every entry of the index and checks the year on each, which here is still cheaper than reading the table. On a large table that is a scan of the whole index; give such a query its own index on year. (PostgreSQL 18's skip scan jumps animal by animal instead.)

    d3-second-column · PostgreSQL with PostGIS

    Runs after: d3-analyze, d3-two-columns

    EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
    SELECT id, month
    FROM occurrences
    WHERE year BETWEEN 1850 AND 1899;

    What the plan must show:

    • the top node returns 107 rows (actual rows=107)
    • a line with occurrences_species_year_idx
    • a line with Index Cond: ((year >= 1850) AND (year <= 1899))
    • no line with Seq Scan

    An example plan, from the versions above; yours passes if it shows the lines listed, whatever else differs.

    -- result: d3-second-column · 5 rows · sha256 71cb12c0a8a4
    QUERY PLAN
    Bitmap Heap Scan on occurrences (actual rows=107 loops=1)
      Recheck Cond: ((year >= 1850) AND (year <= 1899))
      Heap Blocks: exact=44
      ->  Bitmap Index Scan on occurrences_species_year_idx (act…
            Index Cond: ((year >= 1850) AND (year <= 1899))

    In d3.sql, with the blocks it runs after.

  9. What the planner believes about occurrences. A negative n_distinct is a share of the rows: −1 means every value is different. id has a correlation of 1 because the loader writes the rows in id order; year is partly ordered (0.62), license a little (0.39), the rest close to 0.

    d3-stats · PostgreSQL with PostGIS

    Runs after: d3-analyze

    SELECT attname AS column_name, null_frac, n_distinct, correlation
    FROM pg_stats
    WHERE schemaname = current_schema()
      AND tablename = 'occurrences'
      AND attname IN ('id', 'species_id', 'year', 'basis_of_record', 'license', 'country_code')
    ORDER BY attname;
    -- result: d3-stats · 6 rows · sha256 0058f437405b
    column_name | null_frac | n_distinct | correlation
    basis_of_record | 0 | 7 | 0.05776447
    country_code | 0 | 106 | -0.164075
    id | 0 | -1 | 1
    license | 0 | 2 | 0.39390513
    species_id | 0 | 71 | 0.023194969
    … 1 more row

    In d3.sql, with the blocks it runs after.

  10. The most common values, and the estimate each gives: the frequency times the rows the planner expects. Because d3-analyze read every row, the estimates are exact; with the default sample they would be close.

    d3-mcv · PostgreSQL with PostGIS

    Runs after: d3-analyze

    SELECT m.value AS basis_of_record,
           round(m.freq * c.reltuples) AS planner_estimate,
           (SELECT count(*) FROM occurrences AS o WHERE o.basis_of_record = m.value) AS actual
    FROM pg_stats AS s
    CROSS JOIN LATERAL unnest(s.most_common_vals::text::text[], s.most_common_freqs) AS m (value, freq)
    JOIN pg_class AS c ON c.oid = 'occurrences'::regclass
    WHERE s.schemaname = current_schema()
      AND s.tablename = 'occurrences'
      AND s.attname = 'basis_of_record'
    ORDER BY m.value;
    -- result: d3-mcv · 7 rows · sha256 16d206284038
    basis_of_record | planner_estimate | actual
    HUMAN_OBSERVATION | 17587 | 17587
    MACHINE_OBSERVATION | 8418 | 8418
    MATERIAL_CITATION | 441 | 441
    MATERIAL_SAMPLE | 1805 | 1805
    OBSERVATION | 1384 | 1384
    … 2 more rows

    In d3.sql, with the blocks it runs after.

  11. Two conditions together: the devil's records in Australia. The planner multiplies the two frequencies, as if an animal's country were chance:

    d3-independent · PostgreSQL with PostGIS

    Runs after: d3-analyze

    WITH mcv AS (
      SELECT s.attname, m.value, m.freq
      FROM pg_stats AS s
      CROSS JOIN LATERAL unnest(s.most_common_vals::text::text[], s.most_common_freqs) AS m (value, freq)
      WHERE s.schemaname = current_schema() AND s.tablename = 'occurrences'
    )
    SELECT round((SELECT freq FROM mcv WHERE attname = 'species_id' AND value = '48')
               * (SELECT freq FROM mcv WHERE attname = 'country_code' AND value = 'AU')
               * (SELECT reltuples FROM pg_class WHERE oid = 'occurrences'::regclass)) AS planner_estimate,
           (SELECT count(*) FROM occurrences WHERE species_id = 48 AND country_code = 'AU') AS actual;
    -- result: d3-independent · 1 row · sha256 a04db70be52f
    planner_estimate | actual
    230 | 1998

    In d3.sql, with the blocks it runs after.

  12. A guess nearly nine times too low is how a planner picks a nested loop where a hash join was needed. Extended statistics on the pair record the combinations that actually occur (mcv), and the planner uses them for both conditions at once:

    d3-extended · PostgreSQL with PostGIS

    CREATE STATISTICS occurrences_species_country (mcv) ON species_id, country_code FROM occurrences;
    SET LOCAL default_statistics_target = 1000;
    ANALYZE occurrences;
    SELECT s.most_common_vals[i][1] AS species_id, s.most_common_vals[i][2] AS country_code,
           round(s.most_common_freqs[i] * c.reltuples) AS planner_estimate
    FROM pg_stats_ext AS s
    CROSS JOIN generate_subscripts(s.most_common_freqs, 1) AS i
    JOIN pg_class AS c ON c.oid = 'occurrences'::regclass
    WHERE s.statistics_name = 'occurrences_species_country'
      AND s.most_common_vals[i][1] = '48'
    ORDER BY planner_estimate DESC;
    -- result: d3-extended · 2 rows · sha256 ea5f3d4586b1
    species_id | country_code | planner_estimate
    48 | AU | 1998
    48 | US | 2

    In d3.sql, with the blocks it runs after.

  13. A spatial join through GiST: the records inside Nepal. The index finds the records whose point is inside Nepal's bounding box (@); ST_Covers then tests each one against the outline, and the box's corners outside Nepal are what it removes:

    d3-gist-join · PostgreSQL with PostGIS

    Runs after: d3-analyze

    EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
    SELECT o.id, o.year
    FROM country_shapes AS cs
    JOIN occurrences AS o ON ST_Covers(cs.geom, o.geom)
    WHERE cs.country_code = 'NP';

    What the plan must show:

    • the top node returns 184 rows (actual rows=184)
    • a line with Index Scan using country_shapes_pkey
    • a line with Index Cond: (geom @ cs.geom)
    • a line with Filter: st_covers(cs.geom, geom)
    • no line with Seq Scan on occurrences

    An example plan, from the versions above; yours passes if it shows the lines listed, whatever else differs.

    -- result: d3-gist-join · 9 rows · sha256 831e30c01c92
    QUERY PLAN
    Nested Loop (actual rows=184 loops=1)
      ->  Index Scan using country_shapes_pkey on country_shapes…
            Index Cond: (country_code = 'NP'::text)
      ->  Bitmap Heap Scan on occurrences o (actual rows=184 loo…
            Filter: st_covers(cs.geom, geom)
    … 4 more rows

    In d3.sql, with the blocks it runs after.

  14. Records within half a degree of a point in Tasmania. (Degrees, because the lab's points are longitude and latitude; later GIS modules measure in metres.) ST_DWithin widens the point into a box half a degree each way, the index finds the records in the box, and the exact distance runs on those only:

    d3-dwithin · PostgreSQL with PostGIS

    Runs after: d3-analyze

    EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
    SELECT o.id, o.year
    FROM admin1_shapes AS a
    JOIN occurrences AS o ON ST_DWithin(o.geom, ST_PointOnSurface(a.geom), 0.5)
    WHERE a.name = 'Tasmania';

    What the plan must show:

    • the top node returns 186 rows (actual rows=186)
    • a line with Bitmap Index Scan on occurrences_geom_idx
    • a line with Index Cond: (geom && st_expand(
    • no line with Seq Scan on occurrences

    An example plan, from the versions above; yours passes if it shows the lines listed, whatever else differs.

    -- result: d3-dwithin · 10 rows · sha256 d06922cb9525
    QUERY PLAN
    Nested Loop (actual rows=186 loops=1)
      ->  Seq Scan on admin1_shapes a (actual rows=1 loops=1)
            Filter: (name = 'Tasmania'::text)
            Rows Removed by Filter: 293
      ->  Bitmap Heap Scan on occurrences o (actual rows=186 loo…
    … 5 more rows

    In d3.sql, with the blocks it runs after.

  15. The same question written with ST_Distance. Same rows, but there is no box to give the index, so every record's distance is computed:

    d3-distance · PostgreSQL with PostGIS

    Runs after: d3-analyze

    EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
    SELECT o.id, o.year
    FROM admin1_shapes AS a
    JOIN occurrences AS o ON ST_Distance(o.geom, ST_PointOnSurface(a.geom)) < 0.5
    WHERE a.name = 'Tasmania';

    What the plan must show:

    • the top node returns 186 rows (actual rows=186)
    • a line with Join Filter: (st_distance(
    • a line with Seq Scan on occurrences o
    • no line with occurrences_geom_idx

    An example plan, from the versions above; yours passes if it shows the lines listed, whatever else differs.

    -- result: d3-distance · 7 rows · sha256 b000623aa7a8
    QUERY PLAN
    Nested Loop (actual rows=186 loops=1)
      Join Filter: (st_distance(o.geom, st_pointonsurface(a.geom…
      Rows Removed by Join Filter: 40209
      ->  Seq Scan on admin1_shapes a (actual rows=1 loops=1)
            Filter: (name = 'Tasmania'::text)
    … 2 more rows

    In d3.sql, with the blocks it runs after.

  16. A BRIN index on year, as the table is stored. A BRIN index returns page ranges, not rows; PostgreSQL counts ten rows per page in its actual rows. The table's 40,395 rows fill seven ranges of 128 pages, and four of them hold some record from the 1850s, so 512 pages are read and rechecked row by row. year's correlation of 0.62 is enough to skip three ranges, not more:

    d3-brin · PostgreSQL with PostGIS

    Runs after: d3-analyze

    CREATE INDEX occurrences_year_brin ON occurrences USING brin (year);
    EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
    SELECT id, species_id
    FROM occurrences
    WHERE year BETWEEN 1850 AND 1859;

    What the plan must show:

    • the top node returns 26 rows (actual rows=26)
    • a line with Bitmap Index Scan on occurrences_year_brin
    • a line with Heap Blocks: lossy=512

    An example plan, from the versions above; yours passes if it shows the lines listed, whatever else differs.

    -- result: d3-brin · 6 rows · sha256 8d3b91175bd6
    QUERY PLAN
    Bitmap Heap Scan on occurrences (actual rows=26 loops=1)
      Recheck Cond: ((year >= 1850) AND (year <= 1859))
      Rows Removed by Index Recheck: 26264
      Heap Blocks: lossy=512
      ->  Bitmap Index Scan on occurrences_year_brin (actual row…
    … 1 more row

    In d3.sql, with the blocks it runs after.

  17. The same index on a copy stored in year order (occurrences_by_year; CLUSTER reorders a table in place, but it locks the table while it does). Now the 1850s sit in the first range only:

    d3-brin-sorted · PostgreSQL with PostGIS

    CREATE TABLE occurrences_by_year AS
    SELECT * FROM occurrences ORDER BY year, id;
    CREATE INDEX occurrences_by_year_brin ON occurrences_by_year USING brin (year);
    SET LOCAL default_statistics_target = 1000;
    ANALYZE occurrences_by_year;
    EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
    SELECT id, species_id
    FROM occurrences_by_year
    WHERE year BETWEEN 1850 AND 1859;

    What the plan must show:

    • the top node returns 26 rows (actual rows=26)
    • a line with Bitmap Index Scan on occurrences_by_year_brin
    • a line with Heap Blocks: lossy=128

    An example plan, from the versions above; yours passes if it shows the lines listed, whatever else differs.

    -- result: d3-brin-sorted · 6 rows · sha256 7f856cb65d9e
    QUERY PLAN
    Bitmap Heap Scan on occurrences_by_year (actual rows=26 loop…
      Recheck Cond: ((year >= 1850) AND (year <= 1859))
      Rows Removed by Index Recheck: 6488
      Heap Blocks: lossy=128
      ->  Bitmap Index Scan on occurrences_by_year_brin (actual …
    … 1 more row

    In d3.sql, with the blocks it runs after.

  18. What each costs on disk: the BRIN index is three pages, a B-tree on the same column about a twentieth of the table.

    d3-sizes · PostgreSQL with PostGIS

    Runs after: d3-brin-sorted

    CREATE INDEX occurrences_by_year_btree ON occurrences_by_year (year);
    SELECT pg_size_pretty(pg_relation_size('occurrences_by_year')) AS table_size,
           pg_size_pretty(pg_relation_size('occurrences_by_year_btree')) AS btree_size,
           pg_size_pretty(pg_relation_size('occurrences_by_year_brin')) AS brin_size;
    -- result: d3-sizes · 1 row · sha256 abcc435e8f56
    table_size | btree_size | brin_size
    6656 kB | 304 kB | 24 kB

    In d3.sql, with the blocks it runs after.

Result

A filter on an unindexed column reads the whole table (40,386 of 40,395 rows thrown away to find the pangolin's nine); a B-tree turns it into six pages, and turns the slug join from a hash join over every record into a nested loop over nine. The planner skips an index when the filter keeps most of the table (72 % of records are from 2000 on), and can't use it when the column is inside an expression (year / 10); rewriting the filter as a range fixes that. A two-column index jumps on its first column; on its second alone, PostgreSQL 16 reads all of it. The planner's beliefs are in pg_stats: exact for one column after a full ANALYZE, but its independence assumption put the devil's Australian records at 230 when there are 1,998, until extended statistics recorded the pair. GiST answers ST_Covers and ST_DWithin through bounding boxes and leaves ST_Distance to a full scan. BRIN is three pages and helps as far as the rows are stored in the column's order: 512 pages read for the 1850s as loaded (partly ordered), 128 on the sorted copy.

Exercise

A game shows an animal's records for a decade, newest first, twenty at a time. For the devil in the 2000s:

d3-ex-rows · PostgreSQL with PostGIS

SELECT id, year, month
FROM occurrences
WHERE species_id = 48 AND year BETWEEN 2000 AND 2009
ORDER BY year DESC, id DESC
LIMIT 20;
-- result: d3-ex-rows · 20 rows · sha256 2be4fa3d7b63
id | year | month
6004147577 | 2009 | NULL
6004147576 | 2009 | NULL
6004147534 | 2009 | NULL
6004147500 | 2009 | NULL
6004147435 | 2009 | NULL
… 15 more rows

In d3.sql, with the blocks it runs after.

As loaded, PostgreSQL reads every record, keeps the devil's 1,390 from the decade and sorts them to return twenty:

d3-ex-before · PostgreSQL with PostGIS

Runs after: d3-analyze

EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT id, year, month
FROM occurrences
WHERE species_id = 48 AND year BETWEEN 2000 AND 2009
ORDER BY year DESC, id DESC
LIMIT 20;

What the plan must show:

An example plan, from the versions above; yours passes if it shows the lines listed, whatever else differs.

-- result: d3-ex-before · 7 rows · sha256 d6e50d56b7a4
QUERY PLAN
Limit (actual rows=20 loops=1)
  ->  Sort (actual rows=20 loops=1)
        Sort Key: year DESC, id DESC
        Sort Method: top-N heapsort  Memory: 26kB
        ->  Seq Scan on occurrences (actual rows=1390 loops=…
… 2 more rows

In d3.sql, with the blocks it runs after.

Create the one index that lets it read just those twenty rows, with no sort and no sequential scan, and show the plan.

Show answer

d3-ex · PostgreSQL with PostGIS

Runs after: d3-analyze

CREATE INDEX occurrences_species_year_id_idx ON occurrences (species_id, year, id);
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT id, year, month
FROM occurrences
WHERE species_id = 48 AND year BETWEEN 2000 AND 2009
ORDER BY year DESC, id DESC
LIMIT 20;

What the plan must show:

  • the top node returns 20 rows (actual rows=20)
  • a line with occurrences_species_year_id_idx
  • a line with Index Cond: ((species_id = 48) AND (year >= 2000) AND (year <= 2009))
  • no line with Sort
  • no line with Seq Scan

An example plan, from the versions above; yours passes if it shows the lines listed, whatever else differs.

-- result: d3-ex · 3 rows · sha256 662b60fd19af
QUERY PLAN
Limit (actual rows=20 loops=1)
  ->  Index Scan Backward using occurrences_species_year_id_…
        Index Cond: ((species_id = 48) AND (year >= 2000) AN…

In d3.sql, with the blocks it runs after.

Check

The answer: an index on the filter's equality column first, then the sort's columns in the sort's order. PostgreSQL reads a B-tree in either direction, so (species_id, year, id) serves year DESC, id DESC backwards; (species_id, year DESC, id DESC) is the same index read forwards.

Compare your plan with the answer's: a Limit over an Index Scan or Index Scan Backward on your index, the top node returning 20 rows, its Index Cond holding both the animal and the years, and no Sort or Seq Scan anywhere. Then run the query itself: its twenty rows equal d3-ex-rows'. An index on (species_id) alone, or on (year, species_id), still needs the Sort.

Dialects

Lab schema: the PostGIS tables

critwire Learn lab database, PostGIS schema learn_curriculum

Every column, in words · The diagram alone