Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

PostgreSQL in Depth  Module 3 – Query plans and indexes

GIN, GiST, SP-GiST, BRIN, hash and bloom

Match PostgreSQL 18 queries to the right index method: GIN for jsonb and arrays, GiST and SP-GiST for ranges and points, BRIN, hash and bloom.

  • Advanced
  • 10 minutes
  • Examples run with PostgreSQL 18.6 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 10 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Match query operators to the index access methods and operator classes that support them
  • Size a BRIN index for append-only time-series data after checking the column's physical correlation
  • Read lossy index behaviour in EXPLAIN from Recheck Cond, Rows Removed by Index Recheck and lossy heap blocks
  • Decide when a hash or a bloom index is worth it

Before you start

What you’ll learn · Before you start

What you will learn

  • Match query operators to the index access methods and operator classes that support them
  • Size a BRIN index for append-only time-series data after checking the column's physical correlation
  • Read lossy index behaviour in EXPLAIN from Recheck Cond, Rows Removed by Index Recheck and lossy heap blocks
  • Decide when a hash or a bloom index is worth it

Before you start

On this page

A B-tree on a jsonb column cannot answer body @> '{"kind": "refund"}' at all, however many rows match, and a BRIN index of 24 kB can do the work of a 6,600 kB B-tree on a column of timestamps. PostgreSQL 18 ships six index methods and the bloom module, and which one can serve a query depends on the operator the query uses, not on the column’s type alone.

You will read the catalogs that say which operators each method serves, index arrays and jsonb with GIN, prevent overlapping bookings with GiST and find nearest points with SP-GiST, size a BRIN index for append-only readings, and judge when hash and bloom indexes earn their place.

The operator decides which index can serve a query

An index is built with an access method (btree, hash, gist, spgist, gin, brin) and, for each column, an operator class: The part of an index definition that says which operators the index can search with for a column's type, such as jsonb_path_ops for GIN. The catalogs pg_opclass and pg_amop list them.: the set of operators that index can search with for that type. The catalogs list them, so “why does the planner ignore my index?” often has an exact answer. The run asks about jsonb:

Which operators each index method serves for jsonb

opclass_session.py

"""Index access methods on PostgreSQL 18.6: opclass.sql."""

from pg_session import Server

# The same plan on every run: autovacuum off, no parallel workers or JIT, statistics from every row, and UTC.
SETTINGS = {"autovacuum": "off", "max_parallel_workers_per_gather": 0, "jit": "off",
            "default_statistics_target": 1000, "timezone": "'UTC'"}

with Server(settings=SETTINGS) as pg:
    pg.up()
    pg.psql("opclass.sql", errors=0)

Output

$ psql -X -a -f opclass.sql
-- The index access methods of this server, and which operators each one can serve for jsonb.
SELECT amname, amtype FROM pg_am WHERE amtype = 'i' ORDER BY amname;
 amname | amtype 
--------+--------
 brin   | i
 btree  | i
 gin    | i
 gist   | i
 hash   | i
 spgist | i
(6 rows)

SELECT am.amname AS method, opc.opcname AS operator_class, opc.opcdefault AS is_default,
       string_agg(DISTINCT op.oprname, ' ' ORDER BY op.oprname) AS operators
FROM pg_opclass AS opc
JOIN pg_am AS am ON am.oid = opc.opcmethod
JOIN pg_amop AS amop ON amop.amopfamily = opc.opcfamily
JOIN pg_operator AS op ON op.oid = amop.amopopr
WHERE opc.opcintype = 'jsonb'::regtype
GROUP BY am.amname, opc.opcname, opc.opcdefault ORDER BY 1, 2;
 method | operator_class | is_default |    operators     
--------+----------------+------------+------------------
 btree  | jsonb_ops      | t          | < <= = > >=
 gin    | jsonb_ops      | t          | ? ?& ?| @> @? @@
 gin    | jsonb_path_ops | f          | @> @? @@
 hash   | jsonb_ops      | t          | =
(4 rows)

-- A B-tree on a jsonb column cannot serve @>, whatever the data: the operator is not in its class.
CREATE TABLE docs (id int PRIMARY KEY, body jsonb NOT NULL);
CREATE TABLE
INSERT INTO docs SELECT n, jsonb_build_object('kind', CASE WHEN n % 100 = 0 THEN 'refund' ELSE 'note' END, 'n', n)
FROM generate_series(1, 50000) AS n;
INSERT 0 50000
CREATE INDEX docs_body_btree ON docs (body);
CREATE INDEX
VACUUM ANALYZE docs;
VACUUM
EXPLAIN (COSTS OFF) SELECT count(*) FROM docs WHERE body @> '{"kind": "refund"}';
                      QUERY PLAN                       
-------------------------------------------------------
 Aggregate
   ->  Seq Scan on docs
         Filter: (body @> '{"kind": "refund"}'::jsonb)
(3 rows)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 opclass_session.py

opclass.sql

-- The index access methods of this server, and which operators each one can serve for jsonb.
SELECT amname, amtype FROM pg_am WHERE amtype = 'i' ORDER BY amname;
SELECT am.amname AS method, opc.opcname AS operator_class, opc.opcdefault AS is_default,
       string_agg(DISTINCT op.oprname, ' ' ORDER BY op.oprname) AS operators
FROM pg_opclass AS opc
JOIN pg_am AS am ON am.oid = opc.opcmethod
JOIN pg_amop AS amop ON amop.amopfamily = opc.opcfamily
JOIN pg_operator AS op ON op.oid = amop.amopopr
WHERE opc.opcintype = 'jsonb'::regtype
GROUP BY am.amname, opc.opcname, opc.opcdefault ORDER BY 1, 2;
-- A B-tree on a jsonb column cannot serve @>, whatever the data: the operator is not in its class.
CREATE TABLE docs (id int PRIMARY KEY, body jsonb NOT NULL);
INSERT INTO docs SELECT n, jsonb_build_object('kind', CASE WHEN n % 100 = 0 THEN 'refund' ELSE 'note' END, 'n', n)
FROM generate_series(1, 50000) AS n;
CREATE INDEX docs_body_btree ON docs (body);
VACUUM ANALYZE docs;
EXPLAIN (COSTS OFF) SELECT count(*) FROM docs WHERE body @> '{"kind": "refund"}';

pg_session.py

"""Throwaway PostgreSQL 18.6 servers for the examples of this lesson.

Every session creates a cluster of its own with initdb in a new folder inside the examples folder, starts it, runs
psql scripts and server programs on it, prints what they printed, and stops and deletes it again, also when something
fails. The server is the pinned PostgreSQL 18.6 from the mise installations next to the Python that runs this file.
It listens only on 127.0.0.1, an address no other computer can reach, on a port the operating system reports as
unused, with no Unix socket, and it asks for a password (scram-sha-256) made up for the run and never printed.
learn:verify's run sandbox cannot read the home folder, where mise keeps PostgreSQL, so these sessions are recorded
with LEARN_VERIFY_NETWORK=allow; they read only this folder and the toolchain and connect to nothing but 127.0.0.1.

The output is exactly what the programs printed, with four substitutions so that a record never shows the computer
it ran on and is the same on every run: the run's folder is shown as ".", the folder of PostgreSQL's programs is left
out of their paths, the operating-system user's name is shown as "learner", and the dots pg_ctl prints while it waits
(one about every tenth of a second) as three. The sessions never print the port. A session stops with status 1 when
a command fails that should have worked, or reports a different number of errors than the session expects, so a run
that went wrong is never recorded as if it had worked.
"""

import os
import pwd
import re
import secrets
import shlex
import shutil
import socket
import subprocess
import sys
import tempfile
import textwrap
import time
from pathlib import Path

PG_VERSION = "18.6"
# .../installs/python/3.14.8/bin/python3 -> .../installs
INSTALLS = Path(sys.executable).resolve().parents[3]
BIN = INSTALLS / "postgres" / PG_VERSION / "bin"
OS_USER = pwd.getpwuid(os.geteuid()).pw_name


def free_port():
    """A TCP port on 127.0.0.1 that nothing listens on now."""
    with socket.socket() as probe:
        probe.bind(("127.0.0.1", 0))
        return probe.getsockname()[1]


class Server:
    """One throwaway cluster: initdb, settings, start; stopped and deleted when the with block ends."""

    def __init__(self, settings=None, initdb=(), name="data"):
        self.settings = dict(settings or {})
        self.initdb_args = list(initdb)
        self.name = name
        self.running = []

    def __enter__(self):
        if not (BIN / "postgres").exists():
            sys.exit(f"PostgreSQL {PG_VERSION} was not found next to this Python")
        self.base = Path(tempfile.mkdtemp(prefix="pg", dir=".")).resolve()
        self.port = free_port()
        self.password = secrets.token_urlsafe(24)
        (self.base / "pwfile").write_text(self.password + "\n")
        self.env = {**os.environ, "PGHOST": "127.0.0.1", "PGPORT": str(self.port), "PGUSER": "postgres",
                    "PGPASSWORD": self.password, "PGDATABASE": "postgres", "PGCONNECT_TIMEOUT": "10"}
        for key in ("PGSERVICE", "PGSERVICEFILE", "PGOPTIONS", "PSQLRC"):
            self.env.pop(key, None)
        for script in Path(".").glob("*.sql"):
            shutil.copy(script, self.base / script.name)
        for data in Path(".").glob("*.csv"):
            shutil.copy(data, self.base / data.name)
        return self

    def __exit__(self, *exc):
        for folder, _port in reversed(self.running):
            self._call(["pg_ctl", "-D", folder, "-m", "fast", "-w", "-t", "60", "stop"])
        self.running = []
        shutil.rmtree(self.base, ignore_errors=True)

    # ---- the cluster ---------------------------------------------------------------------------------------------
    def initdb(self, show=False):
        args = ["initdb", "-D", self.name, "-U", "postgres", "--auth=scram-sha-256", "--pwfile=pwfile",
                *self.initdb_args]
        if not show:
            args.append("--no-instructions")
        self._expect(args, show=show)
        with open(self.base / self.name / "postgresql.conf", "a") as conf:
            conf.write("\n# Added by the session: 127.0.0.1 only, no Unix socket.\n")
            conf.write(f"listen_addresses = '127.0.0.1'\nport = {self.port}\nunix_socket_directories = ''\n")
            for key, value in self.settings.items():
                conf.write(f"{key} = {value}\n")

    def start(self, show=False, folder=None, port=None):
        folder = folder or self.name
        self._expect(["pg_ctl", "-D", folder, "-l", f"{folder}.log", "-w", "-t", "60", "start"], show=show)
        self.running.append((folder, port or self.port))

    def up(self):
        """initdb and start, quietly."""
        self.initdb()
        self.start()
        return self

    def standby(self, folder="standby"):
        """A streaming standby of this server, made with pg_basebackup -R, started on a port of its own."""
        self._expect(["pg_basebackup", "-D", folder, "-R", "-X", "stream", "-c", "fast"])
        port = free_port()
        with open(self.base / folder / "postgresql.conf", "a") as conf:
            conf.write(f"port = {port}\n")
        self.start(folder=folder, port=port)
        for _ in range(300):
            if self.query("SELECT count(*) FROM pg_stat_replication WHERE state = 'streaming'") == "1":
                return port
            time.sleep(0.1)
        sys.exit("the standby did not start streaming")

    # ---- running things ------------------------------------------------------------------------------------------
    def _call(self, args, env=None):
        args = [str(a) for a in args]
        done = subprocess.run([str(BIN / args[0]), *args[1:]], stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                              text=True, cwd=self.base, env=env or self.env)
        return done.returncode, self.clean(done.stdout)

    def _expect(self, args, show=False, status=0):
        code, out = self._call(args)
        if show:
            _show(args, out)
        if code != status:
            if not show:
                print(out)
            sys.exit(f"{args[0]} ended with status {code}, not {status}")
        return out

    def clean(self, text):
        """The run's folder as ".", the user as learner, pg_ctl's waiting dots as three (see the docstring)."""
        text = text.replace(str(BIN) + "/", "").replace(str(self.base) + "/", "").replace(str(self.base), ".")
        text = re.sub(r"(waiting for server to (?:start|shut down))\.+", r"\1...", text)
        return text.replace(f'"{OS_USER}"', '"learner"')

    def wait_for(self, sql, value):
        """Waits (unprinted) until a query returns the value, for example after a reload."""
        for _ in range(300):
            if self.query(sql) == value:
                return
            time.sleep(0.1)
        sys.exit(f"{sql} never returned {value}")

    def show_file(self, path):
        """Prints a text file of the cluster, as `cat` would."""
        print(f"$ cat {path}")
        print((self.base / path).read_text().rstrip("\n"))
        print()

    def query(self, sql, port=None):
        """One value from one query, unprinted (the session uses it to wait or to decide)."""
        env = {**self.env, "PGPORT": str(port or self.port)}
        code, out = self._call(["psql", "-X", "-At", "-c", sql], env=env)
        if code != 0:
            sys.exit(f"the query failed: {out}")
        return out.strip()

    def silent(self, sql):
        """Runs a statement without printing it (used only to give a role the run's password)."""
        self.query(sql)

    def psql(self, script, errors=0, status=None, user=None, db=None, port=None, flags=("-a",), note=None):
        """Runs a psql script with -X (no ~/.psqlrc) and the given flags, and prints the command and its output.
        `errors` is how many ERROR lines the script must print; `status` the exit status it must end with."""
        env = {**self.env, "PGPORT": str(port or self.port)}
        if user:
            env["PGUSER"] = user
        if db:
            env["PGDATABASE"] = db
        args = ["psql", "-X", *flags, "-f", script]
        code, out = self._call(args, env=env)
        shown = ["psql", "-X", *flags, "-f", script]
        if user:
            shown[1:1] = ["-U", user]
        if db:
            shown[1:1] = ["-d", db]
        if note:
            print(f"-- {note}")
        _show(shown, out)
        found = sum(1 for line in out.splitlines() if "ERROR:" in line or ": error:" in line)
        expected_status = 0 if status is None else status
        if found != errors or code != expected_status:
            sys.exit(f"{script}: {found} errors and status {code}; the session expects {errors} and {expected_status}")

    def sessions(self, count):
        """Opens `count` idle psql connections (each one a client backend) until the with block ends."""
        return _Idle(self, count)

    def shell(self, *args, status=0):
        """Runs one of PostgreSQL's programs and prints the command and its output."""
        return self._expect(list(args), show=True, status=status)

    def listing(self, folder, prefix=""):
        """Prints the names in a folder of the cluster (sorted, folders with a slash), as `ls -p` would list them."""
        path = self.base / folder
        names = sorted(n + ("/" if (path / n).is_dir() else "") for n in os.listdir(path) if n.startswith(prefix))
        print(f"-- in {folder}" + (f", the names that start with {prefix}" if prefix else "") + ":")
        print(textwrap.fill("  ".join(names), width=78, initial_indent="   ", subsequent_indent="   ",
                            break_on_hyphens=False))
        print()
        return names


def _arg(a):
    """A command-line argument as a reader would type it: double quotes around text with single quotes in it."""
    a = str(a)
    if re.fullmatch(r"[\w@%+=:,./-]+", a):
        return a
    if not re.search(r"[\"$`\\!]", a):
        return f'"{a}"'
    return shlex.quote(a)


def _show(args, out):
    print("$ " + " ".join(_arg(a) for a in args))
    if out.strip():
        print(out.rstrip("\n"))
    print()


class _Idle:
    """Idle psql connections, closed again when the with block ends."""

    def __init__(self, server, count):
        self.server, self.count, self.procs = server, count, []

    def __enter__(self):
        before = int(self.server.query("SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend'"))
        for _ in range(self.count):
            self.procs.append(subprocess.Popen([str(BIN / "psql"), "-X", "-q"], stdin=subprocess.PIPE,
                                               stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
                                               env=self.server.env, cwd=self.server.base))
        sql = "SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend'"
        for _ in range(300):
            # the query's own connection is one more client backend
            if int(self.server.query(sql)) == before + self.count:
                return self
            time.sleep(0.1)
        sys.exit("the idle connections did not open")

    def __exit__(self, *exc):
        for p in self.procs:
            p.stdin.close()
            p.wait(timeout=30)

For jsonb, the B-tree class serves <, <=, =, >= and >, which compare whole values; the default GIN class jsonb_ops serves ?, ?&, ?|, @>, @? and @@; jsonb_path_ops serves only @>, @? and @@; hash serves =. So the B-tree docs_body_btree could not help body @> …: the plan was a Seq Scan, because @> is not in the B-tree class. Choose the method from the operator the query uses:

Interactive · answer and compare Which index method fits the query? Answer two questions about the condition your query tests and your data, and the table rules out the methods that cannot serve it.
What does the query test?
What else is true of the table?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Seven index methods against the conditions they serve and the space they take
Method Equality, = Ranges and ORDER BY Arrays, jsonb, text Overlap and nearest Small on a large table
B-tree Created with: CREATE INDEX … (col) Good the default for = on ordered types Good <, BETWEEN, ORDER BY … LIMIT Poor no @> for arrays or jsonb Poor no overlap or distance operators Mixed 6600 kB for 300,000 timestamps
Hash Created with: USING hash (col) Good = only, straight to a bucket Poor no order: > fell back to a Seq Scan Poor not supported Poor not supported Mixed 4112 kB against 5792 kB for long text keys
GIN Created with: USING gin (col) Mixed on elements, through @> or ? Poor no ordering Good arrays @>, jsonb @> and ?, tsvector @@ Poor not its purpose Mixed 2800 kB for 50,000 jsonb bodies
GiST Created with: USING gist (col) Mixed with btree_gist, for exclusion constraints Mixed range types and their operators Mixed text search, as an alternative to GIN Good overlap &&, nearest <->, EXCLUDE Mixed grows with the data
SP-GiST Created with: USING spgist (col) Mixed points, text prefixes, inet Poor no general ordering Poor not for arrays or jsonb Good points and nearest-neighbour ORDER BY Mixed grows with the data
BRIN Created with: USING brin (col) Mixed only if values follow the table order Good ranges on correlated columns, rechecked Poor not supported Poor not its purpose Good 24 kB for 300,000 rows: one entry per 128 pages
Bloom (contrib) Created with: USING bloom (a, b, c …) Good = on any combination of its columns, rechecked Poor equality only Poor not supported Poor not supported Mixed 3152 kB for six columns, one index
How the answers decide
  • “What does the query test?” Equality on one column: rules out GIN, SP-GiST and BRIN and adds a caveat to Hash. B-tree serves equality and much more; hash serves only equality, which is worth it mainly for long keys on large tables.
  • “What does the query test?” A range or a sort order: rules out Hash, GIN, SP-GiST and Bloom (contrib) and adds a caveat to GiST. Ranges and ORDER BY need an ordered method; GiST helps only for range types with their own operators.
  • “What does the query test?” Elements inside a value: rules out B-tree, Hash, SP-GiST, BRIN and Bloom (contrib) and adds a caveat to GiST. Containment of array elements and jsonb keys is what GIN's inverted index is for.
  • “What does the query test?” Overlap, distance or nearest: rules out B-tree, Hash, GIN, BRIN and Bloom (contrib). Overlap and distance need GiST or SP-GiST operator classes, the only ones with && and <->.
  • “What else is true of the table?” Filters combine many columns: rules out Hash, GIN, GiST, SP-GiST and BRIN and adds a caveat to B-tree. One bloom index serves equality on any subset of its columns; B-trees would need one per useful combination.
  • “What else is true of the table?” Rows arrive in any order: rules out BRIN. A BRIN summary per block range is useless when every range holds values from everywhere.

Operator classes matter within one method too. In a database whose collation is not C, a B-tree with the default class for text cannot serve a prefix search such as LIKE 'abc%', because it orders values by the collation’s rules; the manual’s answer is a B-tree with text_pattern_ops, which compares character by character.

Checkpoint

Check: Access methods and operator classes

  1. Question 1 of 2 According to the recorded catalog query, which index method and class can serve body @> '{"kind": "refund"}' on a jsonb column?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: GIN, with jsonb_ops or jsonb_path_ops

    pg_opclass and pg_amop list each class's operators. Pick the method from the operator the query uses.

  2. Question 2 of 2 A table has a B-tree index on a jsonb column, and a query with @> on that column runs a Seq Scan. What is the most likely reason?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The operator @> is not in the B-tree operator class for jsonb, so that index cannot serve the query

    "Why is my index not used?" often means the wrong method or class for the operator, which the catalogs answer exactly.

GIN indexes the elements inside a value

GIN: Generalized Inverted Index: an index that stores, for every element of a composite value (an array item, a jsonb key or value, a word of a tsvector), the rows that contain it. is an inverted index: for every element it stores the rows that contain it, an array’s items, a jsonb document’s keys and values, a tsvector’s words. A query for documents containing two tags looks up both and intersects the two lists. The run indexes 50,000 documents twice:

GIN on a text array and on jsonb

gin_session.py

"""Index access methods on PostgreSQL 18.6: gin.sql."""

from pg_session import Server

# The same plan on every run: autovacuum off, no parallel workers or JIT, statistics from every row, and UTC.
SETTINGS = {"autovacuum": "off", "max_parallel_workers_per_gather": 0, "jit": "off",
            "default_statistics_target": 1000, "timezone": "'UTC'"}

with Server(settings=SETTINGS) as pg:
    pg.up()
    pg.psql("gin.sql", errors=0)

Output

$ psql -X -a -f gin.sql
-- 50,000 documents with tags and a jsonb body; 1 in 100 is a refund.
CREATE TABLE docs (id int PRIMARY KEY, tags text[] NOT NULL, body jsonb NOT NULL);
CREATE TABLE
INSERT INTO docs
SELECT n, ARRAY['t' || (n % 50), 't' || (n % 7)],
       jsonb_build_object('kind', CASE WHEN n % 100 = 0 THEN 'refund' ELSE 'note' END, 'n', n)
FROM generate_series(1, 50000) AS n;
INSERT 0 50000
CREATE INDEX docs_tags ON docs USING gin (tags);
CREATE INDEX
CREATE INDEX docs_body ON docs USING gin (body jsonb_path_ops);
CREATE INDEX
VACUUM ANALYZE docs;
VACUUM
-- An array containing two tags: GIN looks up each tag and intersects the two lists of rows.
EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF) SELECT count(*) FROM docs WHERE tags @> ARRAY['t3', 't5'];
                                                  QUERY PLAN                                                   
---------------------------------------------------------------------------------------------------------------
 Aggregate  (cost=779.44..779.45 rows=1 width=8) (actual rows=1.00 loops=1)
   Buffers: shared hit=298
   ->  Bitmap Heap Scan on docs  (cost=28.16..776.24 rows=1280 width=0) (actual rows=286.00 loops=1)
         Recheck Cond: (tags @> '{t3,t5}'::text[])
         Heap Blocks: exact=286
         Buffers: shared hit=298
         ->  Bitmap Index Scan on docs_tags  (cost=0.00..27.84 rows=1280 width=0) (actual rows=286.00 loops=1)
               Index Cond: (tags @> '{t3,t5}'::text[])
               Index Searches: 1
               Buffers: shared hit=12
 Planning:
   Buffers: shared hit=22 read=2
(12 rows)

-- A jsonb containment test through the jsonb_path_ops class.
EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF) SELECT count(*) FROM docs WHERE body @> '{"kind": "refund"}';
                                                   QUERY PLAN                                                    
-----------------------------------------------------------------------------------------------------------------
 Aggregate  (cost=1270.70..1270.71 rows=1 width=8) (actual rows=1.00 loops=1)
   Buffers: shared hit=503
   ->  Bitmap Heap Scan on docs  (cost=181.08..1208.26 rows=24975 width=0) (actual rows=500.00 loops=1)
         Recheck Cond: (body @> '{"kind": "refund"}'::jsonb)
         Heap Blocks: exact=500
         Buffers: shared hit=503
         ->  Bitmap Index Scan on docs_body  (cost=0.00..174.83 rows=24975 width=0) (actual rows=500.00 loops=1)
               Index Cond: (body @> '{"kind": "refund"}'::jsonb)
               Index Searches: 1
               Buffers: shared hit=3
 Planning:
   Buffers: shared hit=13
(12 rows)

-- The ->> operator is not in any GIN class for jsonb: the same question asked this way scans the table.
EXPLAIN (COSTS OFF) SELECT count(*) FROM docs WHERE body ->> 'kind' = 'refund';
                         QUERY PLAN                         
------------------------------------------------------------
 Aggregate
   ->  Seq Scan on docs
         Filter: ((body ->> 'kind'::text) = 'refund'::text)
(3 rows)

SELECT relname, pg_size_pretty(pg_relation_size(oid)) AS size FROM pg_class
WHERE relname IN ('docs', 'docs_tags', 'docs_body') ORDER BY relname;
  relname  |  size   
-----------+---------
 docs      | 5720 kB
 docs_body | 2800 kB
 docs_tags | 296 kB
(3 rows)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 gin_session.py

gin.sql

-- 50,000 documents with tags and a jsonb body; 1 in 100 is a refund.
CREATE TABLE docs (id int PRIMARY KEY, tags text[] NOT NULL, body jsonb NOT NULL);
INSERT INTO docs
SELECT n, ARRAY['t' || (n % 50), 't' || (n % 7)],
       jsonb_build_object('kind', CASE WHEN n % 100 = 0 THEN 'refund' ELSE 'note' END, 'n', n)
FROM generate_series(1, 50000) AS n;
CREATE INDEX docs_tags ON docs USING gin (tags);
CREATE INDEX docs_body ON docs USING gin (body jsonb_path_ops);
VACUUM ANALYZE docs;
-- An array containing two tags: GIN looks up each tag and intersects the two lists of rows.
EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF) SELECT count(*) FROM docs WHERE tags @> ARRAY['t3', 't5'];
-- A jsonb containment test through the jsonb_path_ops class.
EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF) SELECT count(*) FROM docs WHERE body @> '{"kind": "refund"}';
-- The ->> operator is not in any GIN class for jsonb: the same question asked this way scans the table.
EXPLAIN (COSTS OFF) SELECT count(*) FROM docs WHERE body ->> 'kind' = 'refund';
SELECT relname, pg_size_pretty(pg_relation_size(oid)) AS size FROM pg_class
WHERE relname IN ('docs', 'docs_tags', 'docs_body') ORDER BY relname;

tags @> ARRAY['t3', 't5'] went through docs_tags and found 286 documents, every heap block exact. The jsonb containment body @> '{"kind": "refund"}' used docs_body, built with jsonb_path_ops, a class whose index items each combine a value with the path of keys above it in one hash, and so is usually much smaller than the default: here 2,800 kB beside the 5,720 kB table. Its estimate was poor, 24,975 rows for 500, yet the index served it: an estimate decides which plan wins, never whether an index is allowed.

The same question written as body ->> 'kind' = 'refund' scanned the whole table: ->> returns text and is in no GIN class. Write jsonb queries with the operators your index serves, or add an expression index on (body ->> 'kind') for that one key. PostgreSQL 18 can also build GIN indexes in parallel, which shortens builds on large tables.

Checkpoint

Check: GIN for elements

  1. Question 1 of 2 How does a GIN index find documents whose tags contain both 't3' and 't5'?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It looks up the rows listed under each tag and intersects the two lists

    Each element of each array is an index key in GIN. The bitmap of matching rows is then read from the table.

  2. Question 2 of 2 Why did body ->> 'kind' = 'refund' scan the table although a GIN index on body served body @> '{"kind": "refund"}'?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: ->> returns text and is in no GIN operator class, so the GIN index cannot serve that condition

    Write jsonb queries with the operators the index serves, or index the expression the query uses.

GiST and SP-GiST answer overlap and nearest-neighbour questions

GiST: Generalized Search Tree: a balanced index of bounding predicates, used for ranges, geometry, full-text search, exclusion constraints and nearest-neighbour ordering. is a balanced tree of bounding predicates: each entry says “everything below me lies within this”, which suits ranges, geometric shapes and text search. One common use is an exclusion constraint, which generalises uniqueness from “equal” to any operator. SP-GiST partitions space instead, without overlap between branches, which suits points, IP addresses and text prefixes. The run uses both:

An exclusion constraint with GiST, and nearest points with SP-GiST

gist_session.py

"""Index access methods on PostgreSQL 18.6: gist.sql."""

from pg_session import Server

# The same plan on every run: autovacuum off, no parallel workers or JIT, statistics from every row, and UTC.
SETTINGS = {"autovacuum": "off", "max_parallel_workers_per_gather": 0, "jit": "off",
            "default_statistics_target": 1000, "timezone": "'UTC'"}

with Server(settings=SETTINGS) as pg:
    pg.up()
    pg.psql("gist.sql", errors=1)

Output

$ psql -X -a -f gist.sql
-- GiST with btree_gist: no two bookings of the same room may overlap in time.
CREATE EXTENSION btree_gist;
CREATE EXTENSION
CREATE TABLE bookings (id int PRIMARY KEY, room int NOT NULL, during tstzrange NOT NULL,
                       EXCLUDE USING gist (room WITH =, during WITH &&));
CREATE TABLE
INSERT INTO bookings VALUES (1, 101, '[2026-03-02 10:00+00, 2026-03-02 11:00+00)');
INSERT 0 1
INSERT INTO bookings VALUES (2, 102, '[2026-03-02 10:30+00, 2026-03-02 12:00+00)');
INSERT 0 1
INSERT INTO bookings VALUES (3, 101, '[2026-03-02 10:30+00, 2026-03-02 12:00+00)');
psql:gist.sql:7: ERROR:  conflicting key value violates exclusion constraint "bookings_room_during_excl"
DETAIL:  Key (room, during)=(101, ["2026-03-02 10:30:00+00","2026-03-02 12:00:00+00")) conflicts with existing key (room, during)=(101, ["2026-03-02 10:00:00+00","2026-03-02 11:00:00+00")).
-- SP-GiST on 100,000 points: the five nearest to (500, 5), straight from the index.
CREATE TABLE places (id int PRIMARY KEY, p point NOT NULL);
CREATE TABLE
INSERT INTO places SELECT n, point(n % 1000, n / 1000) FROM generate_series(1, 100000) AS n;
INSERT 0 100000
CREATE INDEX places_p ON places USING spgist (p);
CREATE INDEX
VACUUM ANALYZE places;
VACUUM
EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF) SELECT id, p FROM places ORDER BY p <-> point(500, 5) LIMIT 5;
                                                   QUERY PLAN                                                    
-----------------------------------------------------------------------------------------------------------------
 Limit  (cost=0.28..0.63 rows=5 width=28) (actual rows=5.00 loops=1)
   Buffers: shared hit=9
   ->  Index Scan using places_p on places  (cost=0.28..6976.28 rows=100000 width=28) (actual rows=5.00 loops=1)
         Order By: (p <-> '(500,5)'::point)
         Index Searches: 1
         Buffers: shared hit=9
 Planning:
   Buffers: shared hit=42 read=2
(8 rows)

SELECT id, p, round((p <-> point(500, 5))::numeric, 2) AS distance FROM places ORDER BY p <-> point(500, 5) LIMIT 5;
  id  |    p    | distance 
------+---------+----------
 5500 | (500,5) |     0.00
 4500 | (500,4) |     1.00
 5501 | (501,5) |     1.00
 5499 | (499,5) |     1.00
 6500 | (500,6) |     1.00
(5 rows)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 gist_session.py

gist.sql

-- GiST with btree_gist: no two bookings of the same room may overlap in time.
CREATE EXTENSION btree_gist;
CREATE TABLE bookings (id int PRIMARY KEY, room int NOT NULL, during tstzrange NOT NULL,
                       EXCLUDE USING gist (room WITH =, during WITH &&));
INSERT INTO bookings VALUES (1, 101, '[2026-03-02 10:00+00, 2026-03-02 11:00+00)');
INSERT INTO bookings VALUES (2, 102, '[2026-03-02 10:30+00, 2026-03-02 12:00+00)');
INSERT INTO bookings VALUES (3, 101, '[2026-03-02 10:30+00, 2026-03-02 12:00+00)');
-- SP-GiST on 100,000 points: the five nearest to (500, 5), straight from the index.
CREATE TABLE places (id int PRIMARY KEY, p point NOT NULL);
INSERT INTO places SELECT n, point(n % 1000, n / 1000) FROM generate_series(1, 100000) AS n;
CREATE INDEX places_p ON places USING spgist (p);
VACUUM ANALYZE places;
EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF) SELECT id, p FROM places ORDER BY p <-> point(500, 5) LIMIT 5;
SELECT id, p, round((p <-> point(500, 5))::numeric, 2) AS distance FROM places ORDER BY p <-> point(500, 5) LIMIT 5;

EXCLUDE USING gist (room WITH =, during WITH &&) accepted bookings for rooms 101 and 102 at overlapping times, then refused a second booking of room 101 that overlapped the first: “conflicting key value violates exclusion constraint”. The btree_gist extension lets GiST compare the plain integer room with = inside the same constraint.

For points, ORDER BY p <-> point(500, 5) LIMIT 5 ran as Index Scan using places_p with Order By: SP-GiST returned the five nearest of 100,000 points, the point itself at distance 0 and four neighbours at distance 1, after 9 buffer hits, without computing every distance. GiST offers the same nearest-neighbour ordering for its classes.

inet and cidr change in PostgreSQL 19

The release notes of PostgreSQL 19, still in preparation, make GiST’s own operator class the default for inet and cidr, because the btree_gist classes for those types could miss rows; pg_upgrade will refuse clusters that still use them.

Checkpoint

Check: GiST and SP-GiST

  1. Question 1 of 2 EXCLUDE USING gist (room WITH =, during WITH &&) is defined. Which booking is refused after room 101 is booked from 10:00 to 11:00?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Room 101 from 10:30 to 12:00

    btree_gist adds GiST classes for plain types such as integers, so = and && can share one constraint.

  2. Question 2 of 2 What did ORDER BY p <-> point(500, 5) LIMIT 5 do with the SP-GiST index places_p?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Returned the five nearest points straight from the index, in distance order, after 9 buffer hits

    Nearest-neighbour ordering with <-> is served by GiST and SP-GiST classes; a B-tree has no distance operator.

BRIN keeps one summary per block range

BRIN: Block Range Index: an index that stores one summary, such as the smallest and largest value, per range of table pages. It is tiny and lossy, and useful when values follow the physical order of the table. stores, for every block range of 128 pages by default, a summary such as the smallest and largest value. It can only say which ranges might match, so it is lossy and every row of those ranges is rechecked; it pays off only when the column’s values follow the physical order of the table. The run loads 300,000 readings, one a minute:

BRIN against a B-tree on 300,000 time-ordered readings

brin_session.py

"""Index access methods on PostgreSQL 18.6: brin.sql."""

from pg_session import Server

# The same plan on every run: autovacuum off, no parallel workers or JIT, statistics from every row, and UTC.
SETTINGS = {"autovacuum": "off", "max_parallel_workers_per_gather": 0, "jit": "off",
            "default_statistics_target": 1000, "timezone": "'UTC'"}

with Server(settings=SETTINGS) as pg:
    pg.up()
    pg.psql("brin.sql", errors=0)

Output

$ psql -X -a -f brin.sql
-- 300,000 readings, one a minute, so the time column follows the physical order of the table.
CREATE TABLE readings (id bigint GENERATED ALWAYS AS IDENTITY, at timestamptz NOT NULL, sensor int NOT NULL,
                       value numeric(6,2) NOT NULL);
CREATE TABLE
INSERT INTO readings (at, sensor, value)
SELECT timestamptz '2026-01-01 00:00+00' + n * interval '1 minute', n % 20, (n % 1000) / 10.0
FROM generate_series(1, 300000) AS n;
INSERT 0 300000
CREATE INDEX readings_at_brin ON readings USING brin (at);
CREATE INDEX
CREATE INDEX readings_at_btree ON readings (at);
CREATE INDEX
VACUUM ANALYZE readings;
VACUUM
SELECT relname, pg_size_pretty(pg_relation_size(oid)) AS size FROM pg_class
WHERE relname IN ('readings', 'readings_at_brin', 'readings_at_btree') ORDER BY relname;
      relname      |  size   
-------------------+---------
 readings          | 17 MB
 readings_at_brin  | 24 kB
 readings_at_btree | 6600 kB
(3 rows)

-- BRIN helps only when values follow the physical order: correlation near 1 or -1.
SELECT attname, round(correlation::numeric, 3) AS correlation FROM pg_stats
WHERE tablename = 'readings' AND attname IN ('at', 'sensor') ORDER BY attname;
 attname | correlation 
---------+-------------
 at      |       1.000
 sensor  |       0.050
(2 rows)

-- One day of readings through the BRIN index alone: whole block ranges, then a recheck of every row in them.
DROP INDEX readings_at_btree;
DROP INDEX
EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF)
SELECT count(*) FROM readings WHERE at >= timestamptz '2026-03-01 00:00+00' AND at < timestamptz '2026-03-02 00:00+00';
                                                                      QUERY PLAN                                                                      
------------------------------------------------------------------------------------------------------------------------------------------------------
 Aggregate  (cost=2472.00..2472.01 rows=1 width=8) (actual rows=1.00 loops=1)
   Buffers: shared hit=133
   ->  Bitmap Heap Scan on readings  (cost=12.39..2468.40 rows=1440 width=0) (actual rows=1440.00 loops=1)
         Recheck Cond: ((at >= '2026-03-01 00:00:00+00'::timestamp with time zone) AND (at < '2026-03-02 00:00:00+00'::timestamp with time zone))
         Rows Removed by Index Recheck: 15968
         Heap Blocks: lossy=128
         Buffers: shared hit=133
         ->  Bitmap Index Scan on readings_at_brin  (cost=0.00..12.03 rows=16667 width=0) (actual rows=1280.00 loops=1)
               Index Cond: ((at >= '2026-03-01 00:00:00+00'::timestamp with time zone) AND (at < '2026-03-02 00:00:00+00'::timestamp with time zone))
               Index Searches: 1
               Buffers: shared hit=5
 Planning:
   Buffers: shared hit=21
(13 rows)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 brin_session.py

brin.sql

-- 300,000 readings, one a minute, so the time column follows the physical order of the table.
CREATE TABLE readings (id bigint GENERATED ALWAYS AS IDENTITY, at timestamptz NOT NULL, sensor int NOT NULL,
                       value numeric(6,2) NOT NULL);
INSERT INTO readings (at, sensor, value)
SELECT timestamptz '2026-01-01 00:00+00' + n * interval '1 minute', n % 20, (n % 1000) / 10.0
FROM generate_series(1, 300000) AS n;
CREATE INDEX readings_at_brin ON readings USING brin (at);
CREATE INDEX readings_at_btree ON readings (at);
VACUUM ANALYZE readings;
SELECT relname, pg_size_pretty(pg_relation_size(oid)) AS size FROM pg_class
WHERE relname IN ('readings', 'readings_at_brin', 'readings_at_btree') ORDER BY relname;
-- BRIN helps only when values follow the physical order: correlation near 1 or -1.
SELECT attname, round(correlation::numeric, 3) AS correlation FROM pg_stats
WHERE tablename = 'readings' AND attname IN ('at', 'sensor') ORDER BY attname;
-- One day of readings through the BRIN index alone: whole block ranges, then a recheck of every row in them.
DROP INDEX readings_at_btree;
EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF)
SELECT count(*) FROM readings WHERE at >= timestamptz '2026-03-01 00:00+00' AND at < timestamptz '2026-03-02 00:00+00';

The BRIN index is 24 kB and the B-tree on the same column 6,600 kB, beside a 17 MB table. pg_stats reports a correlation of 1.000 for at, perfectly in table order, and 0.050 for sensor, which BRIN could not serve. Step through the one-day query:

Interactive · step by step One day of readings through a BRIN index Step through the lesson's query for the readings of one day and see what the 24 kB index knows, and what the table scan still has to check.
One day of readings through a BRIN index, step 5 of 5. Showing One day of readings, readings_at_brin, Earlier ranges (skipped), One block range (Heap Blocks: lossy=128), Recheck every row (1,440 kept, 15,968 removed). In focus: One day of readings, readings_at_brin.One day of readings1,440 rows wantedreadings_at_brin24 kB of summariesEarlier rangesskippedOne block rangeHeap Blocks: lossy=128Recheck every row1,440 kept, 15,968 removed

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. A summary per block range. The index holds one entry for every 128 pages of the table, the smallest and largest time in them. 17 MB of readings need only 24 kB of such summaries.
  2. Ranges that cannot match. For the query's day, every range whose largest time is earlier, or whose smallest is later, is skipped without reading a page. Time grows with the table, correlation 1.000, so almost all ranges are skipped.
  3. A range that may match. One block range overlaps the day. The index cannot say which of its rows match, so it marks all 128 pages, a lossy result.
  4. The recheck. The bitmap heap scan reads the 128 pages and tests the time of every row on them. It keeps 1,440 readings and removes 15,968, the Rows Removed by Index Recheck of the plan.
  5. Why it was still cheap. 133 buffer hits answered the query. A B-tree on the same column needed 6600 kB; BRIN trades a recheck of a few pages for an index 275 times smaller.

The plan’s lossy lines say what happened: Heap Blocks: lossy=128, Recheck Cond on the time range, and Rows Removed by Index Recheck: 15968, the rows of the 128 pages that were outside the day. A smaller pages_per_range makes the summaries finer and the index larger. A B-tree bitmap can turn lossy too, when work_mem is too small for an exact bitmap of every matching row, and then shows the same lines.

Key idea

BRIN answers “which block ranges might hold this value?”, not “which rows”: it is tiny and fast when values follow the table’s physical order, and nearly useless when they do not.

Checkpoint

Check: BRIN and lossy scans

  1. Question 1 of 2 pg_stats shows correlation 1.000 for at and 0.050 for sensor. On which column is a BRIN index worth trying?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: at, because its values follow the physical order of the table, so most block ranges can be skipped

    Check correlation before building BRIN; values near 1 or -1 make it effective.

  2. Question 2 of 2 The BRIN plan read one block range of 128 pages, kept 1,440 rows and showed Rows Removed by Index Recheck 15968. How many rows did the recheck test?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 17408 rows

    1,440 + 15,968 = 17,408: every row of the 128 lossy pages was tested against the time range, about 136 rows a page.

Hash and bloom serve equality, and only equality

A hash index stores a 32-bit hash of each value and finds a value’s bucket directly. Since PostgreSQL 10 it is WAL-logged, which made it crash-safe and replicated, so it is a real option, but it serves only =. The bloom module builds one index over many columns, storing a small signature per row, and serves equality on any combination of them, with false positives rechecked against the table:

A hash index on tokens, and a bloom index on six columns

hashbloom_session.py

"""Index access methods on PostgreSQL 18.6: hashbloom.sql."""

from pg_session import Server

# The same plan on every run: autovacuum off, no parallel workers or JIT, statistics from every row, and UTC.
SETTINGS = {"autovacuum": "off", "max_parallel_workers_per_gather": 0, "jit": "off",
            "default_statistics_target": 1000, "timezone": "'UTC'"}

with Server(settings=SETTINGS) as pg:
    pg.up()
    pg.psql("hashbloom.sql", errors=0)

Output

$ psql -X -a -f hashbloom.sql
-- 100,000 tokens: a hash index and a B-tree on the same column.
CREATE TABLE tokens (token text NOT NULL);
CREATE TABLE
INSERT INTO tokens SELECT md5(n::text) FROM generate_series(1, 100000) AS n;
INSERT 0 100000
CREATE INDEX tokens_hash ON tokens USING hash (token);
CREATE INDEX
CREATE INDEX tokens_btree ON tokens (token);
CREATE INDEX
VACUUM ANALYZE tokens;
VACUUM
SELECT relname, pg_size_pretty(pg_relation_size(oid)) AS size FROM pg_class
WHERE relname IN ('tokens_hash', 'tokens_btree') ORDER BY relname;
   relname    |  size   
--------------+---------
 tokens_btree | 5792 kB
 tokens_hash  | 4112 kB
(2 rows)

DROP INDEX tokens_btree;
DROP INDEX
EXPLAIN (COSTS OFF) SELECT * FROM tokens WHERE token = 'c4ca4238a0b923820dcc509a6f75849b';
                            QUERY PLAN                            
------------------------------------------------------------------
 Index Scan using tokens_hash on tokens
   Index Cond: (token = 'c4ca4238a0b923820dcc509a6f75849b'::text)
(2 rows)

EXPLAIN (COSTS OFF) SELECT * FROM tokens WHERE token > 'ffff';
            QUERY PLAN            
----------------------------------
 Seq Scan on tokens
   Filter: (token > 'ffff'::text)
(2 rows)

-- bloom (a contrib module): one index on six columns for equality on any combination of them.
CREATE EXTENSION bloom;
CREATE EXTENSION
CREATE TABLE trips (a int, b int, c int, d int, e int, f int);
CREATE TABLE
INSERT INTO trips SELECT n % 97, n % 89, n % 83, n % 79, n % 73, n % 71 FROM generate_series(1, 200000) AS n;
INSERT 0 200000
CREATE INDEX trips_bloom ON trips USING bloom (a, b, c, d, e, f);
CREATE INDEX
VACUUM ANALYZE trips;
VACUUM
EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF) SELECT count(*) FROM trips WHERE c = 5 AND e = 9;
                                                   QUERY PLAN                                                    
-----------------------------------------------------------------------------------------------------------------
 Aggregate  (cost=3692.65..3692.66 rows=1 width=8) (actual rows=1.00 loops=1)
   Buffers: shared hit=507
   ->  Bitmap Heap Scan on trips  (cost=3576.01..3692.57 rows=33 width=0) (actual rows=33.00 loops=1)
         Recheck Cond: ((c = 5) AND (e = 9))
         Rows Removed by Index Recheck: 84
         Heap Blocks: exact=114
         Buffers: shared hit=507
         ->  Bitmap Index Scan on trips_bloom  (cost=0.00..3576.00 rows=33 width=0) (actual rows=117.00 loops=1)
               Index Cond: ((c = 5) AND (e = 9))
               Index Searches: 1
               Buffers: shared hit=393
 Planning:
   Buffers: shared hit=19
(13 rows)

SELECT relname, pg_size_pretty(pg_relation_size(oid)) AS size FROM pg_class
WHERE relname IN ('trips', 'trips_bloom') ORDER BY relname;
   relname   |   size   
-------------+----------
 trips       | 10192 kB
 trips_bloom | 3152 kB
(2 rows)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 hashbloom_session.py

hashbloom.sql

-- 100,000 tokens: a hash index and a B-tree on the same column.
CREATE TABLE tokens (token text NOT NULL);
INSERT INTO tokens SELECT md5(n::text) FROM generate_series(1, 100000) AS n;
CREATE INDEX tokens_hash ON tokens USING hash (token);
CREATE INDEX tokens_btree ON tokens (token);
VACUUM ANALYZE tokens;
SELECT relname, pg_size_pretty(pg_relation_size(oid)) AS size FROM pg_class
WHERE relname IN ('tokens_hash', 'tokens_btree') ORDER BY relname;
DROP INDEX tokens_btree;
EXPLAIN (COSTS OFF) SELECT * FROM tokens WHERE token = 'c4ca4238a0b923820dcc509a6f75849b';
EXPLAIN (COSTS OFF) SELECT * FROM tokens WHERE token > 'ffff';
-- bloom (a contrib module): one index on six columns for equality on any combination of them.
CREATE EXTENSION bloom;
CREATE TABLE trips (a int, b int, c int, d int, e int, f int);
INSERT INTO trips SELECT n % 97, n % 89, n % 83, n % 79, n % 73, n % 71 FROM generate_series(1, 200000) AS n;
CREATE INDEX trips_bloom ON trips USING bloom (a, b, c, d, e, f);
VACUUM ANALYZE trips;
EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF) SELECT count(*) FROM trips WHERE c = 5 AND e = 9;
SELECT relname, pg_size_pretty(pg_relation_size(oid)) AS size FROM pg_class
WHERE relname IN ('trips', 'trips_bloom') ORDER BY relname;

On 100,000 32-character tokens the hash index took 4,112 kB against 5,792 kB for a B-tree, and served token = … with Index Scan using tokens_hash. token > 'ffff' fell back to a Seq Scan: a hash has no order. Hash pays off for equality lookups on long keys in large tables, where its size and its direct bucket access beat a B-tree’s descent.

The bloom index covered all six columns of trips in 3,152 kB. For c = 5 AND e = 9 it returned 117 candidate rows; the recheck removed 84 false positives (Rows Removed by Index Recheck: 84) and kept the 33 real matches. One B-tree per column would also serve such queries, by combining two bitmaps; bloom does it with one index. Bloom fits wide tables queried on unpredictable column combinations; it supports only equality, and only int4 and text out of the box.

Checkpoint

Check: Hash and bloom indexes

  1. Question 1 of 2 The hash index tokens_hash served token = '…'. What did the planner do for token > 'ffff'?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A Seq Scan, because a hash index has no order and serves only equality

    A hash index answers one question, equality, in exchange for a smaller index on long keys: 4,112 kB against 5,792 kB here.

  2. Question 2 of 2 For c = 5 AND e = 9 the bloom index returned 117 candidate rows and the recheck removed 84. How many rows matched the query?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 33 rows

    117 − 84 = 33. A bloom signature can claim a match that is not there, so every candidate is rechecked against the table.

Practise: choose an index for each workload

The questions use the recorded runs: the operator classes, the GIN and GiST plans, BRIN’s ranges and the bloom recheck. Before you start, name the method you would choose for “log lines appended by time, queried by hour”:

Practice that adapts to you

Practise this lesson

10 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 10 Which catalog lists the index access methods of a PostgreSQL server?

    Choose one answer.

    Show the answer: Question 1 of 10

    Answer: pg_am, where index methods have amtype i

    pg_am names the methods, pg_opclass the classes per type, pg_amop the operators of each class's family.

  2. Question 2 of 10 Queries on a jsonb column use only @>. Which GIN operator class fits, and what do you give up?

    Choose one answer.

    Show the answer: Question 2 of 10

    Answer: jsonb_path_ops, usually smaller and faster for @>, giving up the key-exists operators ?, ?| and ?&

    Choose the operator class from the operators your queries use; CREATE INDEX … USING gin (body jsonb_path_ops).

  3. Question 3 of 10 Which of these values can GIN index by their elements?

    Choose one answer.

    Show the answer: Question 3 of 10

    Answer: Arrays, jsonb documents and tsvector values

    Full-text search with tsvector is the subject of this track's search module.

  4. Question 4 of 10 A jsonb column is updated on every request and searched once an hour. What should you consider before adding a GIN index?

    Choose one answer.

    Show the answer: Question 4 of 10

    Answer: GIN indexes are costlier to update than B-trees, so an expression index on the one key searched, or none, may fit better

    Index for the queries that matter, weighed against the writes that pay for the index.

  5. Question 5 of 10 How do GiST and SP-GiST differ in the way they organise data?

    Choose one answer.

    Show the answer: Question 5 of 10

    Answer: GiST is a balanced tree of possibly overlapping bounding predicates; SP-GiST partitions the space into parts that do not overlap

    Both support <-> ordering for suitable classes; choose by the shape of the data.

  6. Question 6 of 10 A booking system checks in the application that a new booking does not overlap existing ones, and sometimes double-books rooms under load. What is the robust fix?

    Choose one answer.

    Show the answer: Question 6 of 10

    Answer: An exclusion constraint with GiST, EXCLUDE USING gist (room WITH =, during WITH &&), with btree_gist for the room

    The recorded constraint refused room 101's overlapping booking with "conflicting key value violates exclusion constraint".

  7. Question 7 of 10 The B-tree on at was 6,600 kB and the BRIN index 24 kB. How many times smaller was BRIN?

    Type a number.

    Show the answer: Question 7 of 10

    Answer: 275 times

    6,600 ÷ 24 = 275. BRIN stores one summary per 128 pages instead of one entry per row.

  8. Question 8 of 10 A one-hour query on the readings rechecks far more rows than it keeps. Which change makes BRIN more precise, and at what cost?

    Choose one answer.

    Show the answer: Question 8 of 10

    Answer: A smaller pages_per_range, which gives finer summaries and a larger index

    The default of 128 pages suits long ranges; short ranges on huge tables may want 16 or 32 pages per range.

  9. Question 9 of 10 When is a hash index worth choosing over a B-tree?

    Choose one answer.

    Show the answer: Question 9 of 10

    Answer: For equality lookups only, on long keys in large tables, where it is smaller and reaches a bucket directly

    For most equality lookups a B-tree is still the safe default, since it also serves ranges, ordering and uniqueness.

  10. Question 10 of 10 A table of trips has six filter columns and users combine any two or three of them with equality. What does a bloom index offer here?

    Choose one answer.

    Show the answer: Question 10 of 10

    Answer: One index that serves equality on any combination of the six columns, at the cost of rechecking false positives

    Bloom ships with operator classes for int4 and text; larger signatures reduce false positives and enlarge the index.

Interview questions

Warm-up (fresher to 2–5 years): When would you choose GIN over a B-tree in PostgreSQL?

Choose GIN when queries ask about elements inside a value rather than about the whole value: an array that contains some items, a jsonb document that contains a key or a key-value pair, a tsvector that contains words. GIN is an inverted index that stores, for each element, the rows containing it, and it serves operators such as @>, ?, ?| and @@. A B-tree compares whole values, as less than, equal to or greater than, so it serves equality, ranges and ORDER BY on scalar columns but not containment. GIN costs more to update than a B-tree, because one row adds an entry for each of its elements, so it fits columns that are searched far more often than they change. For one jsonb key queried with ->>, an expression B-tree on that key is often better.

Two questions go further: a log table where a 40 GB B-tree on the timestamp is being replaced, and a search on many optional filters. Both are set out on this module’s question page, with the move a strong answer opens with:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    An append-only log table holds two billion rows and its B-tree on logged_at takes 40 GB. Most queries ask for one hour or one day. A colleague proposes replacing the B-tree with BRIN. How do you decide, and what could go wrong?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    A search page filters a 50-million-row listings table on any combination of eight optional fields: city, type, bedrooms, furnished, pets, parking, floor and owner. Users pick two or three at a time. How do you index it?

    Model answer, Pro. First step and follow-ups

Flashcards · 5 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. What decides which operators an index serves?

    The access method and the operator class. pg_opclass and pg_amop list them; for example a B-tree on jsonb cannot serve @>.

  2. What does a GIN index serve?

    It indexes the elements of arrays, jsonb and tsvector and serves containment operators (not ->>). The smaller jsonb_path_ops class serves only @>, @? and @@.

  3. What do GiST and SP-GiST indexes serve?

    GiST serves overlap, exclusion constraints and nearest-neighbour ordering. SP-GiST partitions space for points, addresses and prefixes.

  4. When does a BRIN index work well?

    It keeps one summary per block range (24 kB vs 6,600 kB here) and works only when values follow the table's physical order. Its results are lossy and rechecked.

  5. What do hash and bloom indexes serve?

    Hash indexes serve only equality and suit long keys. Bloom indexes serve equality on any combination of many columns, rechecking their false positives.

Key takeaways

  • The access method and the operator class decide which operators an index serves; pg_opclass and pg_amop list them, and a B-tree on jsonb cannot serve @>.
  • GIN indexes the elements of arrays, jsonb and tsvector and serves containment operators, not ->>; the smaller jsonb_path_ops class serves only @>, @? and @@.
  • GiST serves overlap, exclusion constraints and nearest-neighbour ordering, and SP-GiST partitions space for points, addresses and prefixes.
  • BRIN keeps one summary per block range, 24 kB against 6,600 kB here, and works only when values follow the table’s physical order; its results are lossy and rechecked.
  • Hash indexes serve only equality and suit long keys, and bloom indexes serve equality on any combination of many columns, rechecking their false positives.

Terms in this lesson

BRIN
Block Range Index: an index that stores one summary, such as the smallest and largest value, per range of table pages. It is tiny and lossy, and useful when values follow the physical order of the table.
GIN
Generalized Inverted Index: an index that stores, for every element of a composite value (an array item, a jsonb key or value, a word of a tsvector), the rows that contain it.
GiST
Generalized Search Tree: a balanced index of bounding predicates, used for ranges, geometry, full-text search, exclusion constraints and nearest-neighbour ordering.
operator class
The part of an index definition that says which operators the index can search with for a column's type, such as jsonb_path_ops for GIN. The catalogs pg_opclass and pg_amop list them.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.