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

Index design: partial, expression, write cost

Design PostgreSQL 18 indexes that pay for themselves: partial and expression indexes, partial unique rules, the WAL each index costs, and the ones to drop.

  • Intermediate
  • 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

  • Write partial and expression indexes whose conditions the planner can prove
  • Implement a business rule with a partial unique index, such as one default address per customer
  • Measure the write cost of each index, including updates that are no longer HOT
  • Find unused, duplicate and invalid indexes from statistics views and catalogs

Before you start

What you’ll learn · Before you start

What you will learn

  • Write partial and expression indexes whose conditions the planner can prove
  • Implement a business rule with a partial unique index, such as one default address per customer
  • Measure the write cost of each index, including updates that are no longer HOT
  • Find unused, duplicate and invalid indexes from statistics views and catalogs

Before you start

On this page

An index that serves 1,000 pending orders out of 100,000 can be 16 kB or 816 kB, depending on whether it holds only those orders or all of them. And an index keeps costing after it is built: in this lesson’s measurements, indexing one frequently updated column made the same 1,000 updates write 6.4 times as much WAL. Good index design picks the rows, the expression and the columns that a query really needs, and no more.

You will build a partial index and see when the planner may use it, index an expression, enforce “one default address per customer” with a partial unique index, measure the WAL that indexes add to updates, and find the indexes a table could do without.

A partial index holds only the rows a query asks for

A partial index: An index built only over the rows that satisfy a WHERE clause in its definition. The planner uses it when the query's own conditions prove that every row it needs is in the index. has a WHERE clause: it contains only the rows that satisfy it. The shop of this lesson has 100,000 orders, 1 in 100 pending, and an often-run query for one customer’s pending orders. The run builds a full index and a partial one, then asks twice:

A full index against a partial index of pending orders

partial_session.py

"""Index design on PostgreSQL 18.6: partial.sql on the orders of setup.sql."""

from pg_session import Server

# The same plan on every run: autovacuum off, no parallel workers or JIT, and statistics from every row.
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("setup.sql", flags=("-q",))
    pg.psql("partial.sql", errors=0)

Output

$ psql -X -q -f setup.sql

$ psql -X -a -f partial.sql
-- A full index on (customer_id, status) against a partial index that holds only the pending orders.
CREATE INDEX orders_customer_status ON orders (customer_id, status);
CREATE INDEX
CREATE INDEX orders_pending ON orders (customer_id) WHERE status = 'pending';
CREATE INDEX
SELECT relname, pg_size_pretty(pg_relation_size(oid)) AS size FROM pg_class
WHERE relname IN ('orders_customer_status', 'orders_pending') ORDER BY relname;
        relname         |  size  
------------------------+--------
 orders_customer_status | 816 kB
 orders_pending         | 16 kB
(2 rows)

-- The query repeats the index's condition, so the planner can prove that the partial index holds every match.
EXPLAIN (COSTS OFF) SELECT id, total FROM orders WHERE status = 'pending' AND customer_id = 42;
                QUERY PLAN                 
-------------------------------------------
 Index Scan using orders_pending on orders
   Index Cond: (customer_id = 42)
(2 rows)

-- A prepared statement planned for any status cannot use it: status could be 'shipped'.
PREPARE by_status(text) AS SELECT id, total FROM orders WHERE status = $1 AND customer_id = 42;
PREPARE
SET plan_cache_mode = force_generic_plan;
SET
EXPLAIN (COSTS OFF) EXECUTE by_status('pending');
                         QUERY PLAN                         
------------------------------------------------------------
 Bitmap Heap Scan on orders
   Recheck Cond: ((customer_id = 42) AND (status = $1))
   ->  Bitmap Index Scan on orders_customer_status
         Index Cond: ((customer_id = 42) AND (status = $1))
(4 rows)

RESET plan_cache_mode;
RESET

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

partial.sql

-- A full index on (customer_id, status) against a partial index that holds only the pending orders.
CREATE INDEX orders_customer_status ON orders (customer_id, status);
CREATE INDEX orders_pending ON orders (customer_id) WHERE status = 'pending';
SELECT relname, pg_size_pretty(pg_relation_size(oid)) AS size FROM pg_class
WHERE relname IN ('orders_customer_status', 'orders_pending') ORDER BY relname;
-- The query repeats the index's condition, so the planner can prove that the partial index holds every match.
EXPLAIN (COSTS OFF) SELECT id, total FROM orders WHERE status = 'pending' AND customer_id = 42;
-- A prepared statement planned for any status cannot use it: status could be 'shipped'.
PREPARE by_status(text) AS SELECT id, total FROM orders WHERE status = $1 AND customer_id = 42;
SET plan_cache_mode = force_generic_plan;
EXPLAIN (COSTS OFF) EXECUTE by_status('pending');
RESET plan_cache_mode;

setup.sql

-- The shop of this lesson: 100,000 orders, 1 in 100 of them pending, with e-mail addresses in mixed case.
CREATE TABLE orders (
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  customer_id int NOT NULL,
  status text NOT NULL,
  total numeric(10,2) NOT NULL,
  email text NOT NULL
);
INSERT INTO orders (customer_id, status, total, email)
SELECT 1 + n % 5000, CASE WHEN n % 100 = 0 THEN 'pending' ELSE 'shipped' END, (n % 900) + 99.5,
       'Customer' || (n % 5000) || '@Example.com'
FROM generate_series(1, 100000) AS n;
VACUUM ANALYZE orders;

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)

The partial index orders_pending is 16 kB against 816 kB for (customer_id, status), because it holds 1,000 entries, not 100,000. Every insert of a shipped order skips it entirely. For status = 'pending' AND customer_id = 42 the planner chose it: Index Scan using orders_pending, with only customer_id = 42 left as the index condition.

The second plan is the catch. A prepared statement with status = $1, planned generically, cannot know that $1 will be 'pending', so it cannot prove the query’s rows are all in the partial index, and it fell back to the full index. The manual’s rule is that the planner uses a partial index only when it can prove the query’s WHERE implies the index’s predicate, and it proves that from the query text, not from the values at run time. Write the predicate into the query as a literal, or keep a full index for parameterised callers.

A partial index the queries never match

An index WHERE status = 'pending' does not serve WHERE status IN ('pending', 'held') or WHERE status <> 'shipped', even when those happen to return the same rows. Put the predicate exactly as the queries write it.

Checkpoint

Check: Partial indexes

  1. Question 1 of 2 Why was orders_pending 16 kB when the index on (customer_id, status) was 816 kB?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It holds entries only for the 1,000 pending orders, not for all 100,000

    Rows outside the predicate never enter the index, so inserts of shipped orders cost it nothing either.

  2. Question 2 of 2 The generic plan of WHERE status = $1 AND customer_id = 42 did not use orders_pending, even when run with 'pending'. Why?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The planner must prove from the query that every needed row is in the index, and $1 could be any status

    Write the predicate as a literal in the query, or keep a full index for callers that pass the status as a parameter. The plan-cache lesson of this track returns to custom and generic plans.

An expression index serves the expression, not the column

Customers type their e-mail addresses in any case, so the shop searches with lower(email). An index on email does not help there, because the query compares the result of a function. An expression index: An index on the result of an immutable expression, such as lower(email), instead of on a plain column. Only queries that use the same expression can use it, and ANALYZE gathers statistics on the expression. stores that result:

lower(email) before and after an index on the expression

expression_session.py

"""Index design on PostgreSQL 18.6: expression.sql on the orders of setup.sql."""

from pg_session import Server

# The same plan on every run: autovacuum off, no parallel workers or JIT, and statistics from every row.
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("setup.sql", flags=("-q",))
    pg.psql("expression.sql", errors=0)

Output

$ psql -X -q -f setup.sql

$ psql -X -a -f expression.sql
-- Searching case-insensitively: without an index on the expression, every row is lowered and compared.
EXPLAIN SELECT id FROM orders WHERE lower(email) = 'customer42@example.com';
                        QUERY PLAN                         
-----------------------------------------------------------
 Seq Scan on orders  (cost=0.00..2531.00 rows=500 width=8)
   Filter: (lower(email) = 'customer42@example.com'::text)
(2 rows)

CREATE INDEX orders_email_lower ON orders (lower(email));
CREATE INDEX
ANALYZE orders;
ANALYZE
-- The index matches the expression, and ANALYZE now keeps statistics on lower(email) as well.
EXPLAIN SELECT id FROM orders WHERE lower(email) = 'customer42@example.com';
                                    QUERY PLAN                                    
----------------------------------------------------------------------------------
 Bitmap Heap Scan on orders  (cost=4.45..76.39 rows=20 width=8)
   Recheck Cond: (lower(email) = 'customer42@example.com'::text)
   ->  Bitmap Index Scan on orders_email_lower  (cost=0.00..4.44 rows=20 width=0)
         Index Cond: (lower(email) = 'customer42@example.com'::text)
(4 rows)

-- The plain column is a different expression: this query cannot use the index.
EXPLAIN (COSTS OFF) SELECT id FROM orders WHERE email = 'customer42@example.com';
                     QUERY PLAN                     
----------------------------------------------------
 Seq Scan on orders
   Filter: (email = 'customer42@example.com'::text)
(2 rows)

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

expression.sql

-- Searching case-insensitively: without an index on the expression, every row is lowered and compared.
EXPLAIN SELECT id FROM orders WHERE lower(email) = 'customer42@example.com';
CREATE INDEX orders_email_lower ON orders (lower(email));
ANALYZE orders;
-- The index matches the expression, and ANALYZE now keeps statistics on lower(email) as well.
EXPLAIN SELECT id FROM orders WHERE lower(email) = 'customer42@example.com';
-- The plain column is a different expression: this query cannot use the index.
EXPLAIN (COSTS OFF) SELECT id FROM orders WHERE email = 'customer42@example.com';

Before the index, the planner scanned all 100,000 rows and estimated rows=500: with no statistics about lower(email), it used a default selectivity of 0.5 %. After CREATE INDEX orders_email_lower ON orders (lower(email)) and an ANALYZE, the plan used the index and estimated rows=20, exactly the 20 orders of that customer, because ANALYZE also gathers statistics on the expressions of indexes. The query on the plain column, email = …, still scanned the table: it is a different expression.

The function must be immutable, always returning the same result for the same input, which lower is. A function that depends on settings, the time or the database, such as one reading now(), cannot be indexed.

Checkpoint

Check: Expression indexes

  1. Question 1 of 2 After CREATE INDEX ON orders (lower(email)), which query can use the index?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: WHERE lower(email) = 'customer42@example.com'

    An expression index matches the expression as written. Keep queries and index definitions in step.

  2. Question 2 of 2 Before the expression index existed, the planner estimated 500 rows for lower(email) = … on 100,000 orders. What fraction of the table, in per cent, did it assume?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 0.5 %

    500 of 100,000 is 0.5 %, a default selectivity used when there are no statistics on the expression. After the index and ANALYZE the estimate was 20, the real number of that customer's orders.

A partial unique index enforces a rule on some rows

A customer may have many addresses but only one default. A unique index on customer_id would allow one address in all; a partial unique index on customer_id WHERE is_default allows any number of others and one default:

One default address per customer

unique_session.py

"""Index design on PostgreSQL 18.6: unique.sql on the orders of setup.sql."""

from pg_session import Server

# The same plan on every run: autovacuum off, no parallel workers or JIT, and statistics from every row.
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("setup.sql", flags=("-q",))
    pg.psql("unique.sql", errors=1)

Output

$ psql -X -q -f setup.sql

$ psql -X -a -f unique.sql
-- Any number of addresses per customer, but only one default address: a partial unique index.
CREATE TABLE addresses (
  id int GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  customer_id int NOT NULL,
  city text NOT NULL,
  is_default boolean NOT NULL
);
CREATE TABLE
CREATE UNIQUE INDEX one_default_address ON addresses (customer_id) WHERE is_default;
CREATE INDEX
INSERT INTO addresses (customer_id, city, is_default)
VALUES (1, 'Pune', true), (1, 'Mumbai', false), (1, 'Goa', false), (2, 'Kochi', true);
INSERT 0 4
-- A second default for customer 1 is refused.
INSERT INTO addresses (customer_id, city, is_default) VALUES (1, 'Nashik', true);
psql:unique.sql:12: ERROR:  duplicate key value violates unique constraint "one_default_address"
DETAIL:  Key (customer_id)=(1) already exists.
-- Moving the default is two changes in one statement order: clear the old one, then set the new one.
BEGIN;
BEGIN
UPDATE addresses SET is_default = false WHERE customer_id = 1 AND is_default;
UPDATE 1
UPDATE addresses SET is_default = true WHERE customer_id = 1 AND city = 'Mumbai';
UPDATE 1
COMMIT;
COMMIT
SELECT customer_id, city, is_default FROM addresses ORDER BY customer_id, id;
 customer_id |  city  | is_default 
-------------+--------+------------
           1 | Pune   | f
           1 | Mumbai | t
           1 | Goa    | f
           2 | Kochi  | t
(4 rows)

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

unique.sql

-- Any number of addresses per customer, but only one default address: a partial unique index.
CREATE TABLE addresses (
  id int GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  customer_id int NOT NULL,
  city text NOT NULL,
  is_default boolean NOT NULL
);
CREATE UNIQUE INDEX one_default_address ON addresses (customer_id) WHERE is_default;
INSERT INTO addresses (customer_id, city, is_default)
VALUES (1, 'Pune', true), (1, 'Mumbai', false), (1, 'Goa', false), (2, 'Kochi', true);
-- A second default for customer 1 is refused.
INSERT INTO addresses (customer_id, city, is_default) VALUES (1, 'Nashik', true);
-- Moving the default is two changes in one statement order: clear the old one, then set the new one.
BEGIN;
UPDATE addresses SET is_default = false WHERE customer_id = 1 AND is_default;
UPDATE addresses SET is_default = true WHERE customer_id = 1 AND city = 'Mumbai';
COMMIT;
SELECT customer_id, city, is_default FROM addresses ORDER BY customer_id, id;

Customer 1 got three addresses, one of them the default. A second default was refused: “duplicate key value violates unique constraint “one_default_address”“, Key (customer_id)=(1) already exists. Moving the default needs the order the transaction used: first clear the old default, then set the new one, so that no moment has two.

Rule Index
One default address per customer UNIQUE (customer_id) WHERE is_default
One open ticket per device UNIQUE (device_id) WHERE status = 'open'
Unique e-mail among accounts that are not deleted UNIQUE (lower(email)) WHERE deleted_at IS NULL

The database enforces each rule for every client and every concurrent transaction, which application checks alone cannot do without races. The last row combines both ideas of this lesson: an expression and a predicate.

Checkpoint

Check: Partial unique indexes

  1. Question 1 of 2 Which index lets customers keep many addresses but only one default?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: CREATE UNIQUE INDEX ON addresses (customer_id) WHERE is_default

    The recorded run accepted three addresses for customer 1 and refused a second default with "duplicate key value violates unique constraint".

  2. Question 2 of 2 To make Mumbai the default instead of Pune, an application first sets Mumbai's is_default to true, then Pune's to false. What happens?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The first UPDATE fails, because for a moment customer 1 would have two defaults

    Clear the old default first, then set the new one, in one transaction, as the recorded run did.

Every index is paid for on the writes that touch it

An insert adds an entry to every index of the table, except partial indexes whose condition the row does not meet. An update usually does too, because the new row version has a new address, unless it is a HOT update: A heap-only tuple update: when no indexed column changes and the new row version fits on the same page, PostgreSQL links it from the old version and adds no index entries.: no indexed column changed (a BRIN index, which only summarises ranges of pages, does not count) and the new version fits on the same page, so the indexes keep pointing at the old version, which links to the new one. The run updates 1,000 stock rows three times, with fillfactor = 85 leaving room on every page, and counts the WAL with EXPLAIN (ANALYZE, WAL):

1,000 updates with one, four and five indexes

writes_session.py

"""Index design on PostgreSQL 18.6: writes.sql on the orders of setup.sql."""

from pg_session import Server

# The same plan on every run: autovacuum off, no parallel workers or JIT, and statistics from every row.
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("setup.sql", flags=("-q",))
    pg.psql("writes.sql", errors=0)

Output

$ psql -X -q -f setup.sql

$ psql -X -a -f writes.sql
-- 10,000 stock rows with room left on every page (fillfactor 85), and a quantity that changes all day.
CREATE TABLE stock (id int PRIMARY KEY, sku text NOT NULL, shelf int NOT NULL, supplier int NOT NULL,
                    qty int NOT NULL) WITH (fillfactor = 85);
CREATE TABLE
INSERT INTO stock SELECT n, 'SKU-' || n, n % 400, n % 60, 100 FROM generate_series(1, 10000) AS n;
INSERT 0 10000
VACUUM ANALYZE stock;
VACUUM
-- 1. Only the primary key: an update of qty changes no indexed column.
EXPLAIN (ANALYZE, WAL, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS OFF) UPDATE stock SET qty = qty - 1 WHERE id % 10 = 0;
                      QUERY PLAN                       
-------------------------------------------------------
 Update on stock (actual rows=0.00 loops=1)
   WAL: records=1000 bytes=71688
   ->  Seq Scan on stock (actual rows=1000.00 loops=1)
         Filter: ((id % 10) = 0)
         Rows Removed by Filter: 9000
(5 rows)

-- 2. Three more indexes, none on qty.
CREATE INDEX stock_sku ON stock (sku);
CREATE INDEX
CREATE INDEX stock_shelf ON stock (shelf);
CREATE INDEX
CREATE INDEX stock_supplier ON stock (supplier);
CREATE INDEX
EXPLAIN (ANALYZE, WAL, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS OFF) UPDATE stock SET qty = qty - 1 WHERE id % 10 = 1;
                      QUERY PLAN                       
-------------------------------------------------------
 Update on stock (actual rows=0.00 loops=1)
   WAL: records=1085 bytes=79554
   ->  Seq Scan on stock (actual rows=1000.00 loops=1)
         Filter: ((id % 10) = 1)
         Rows Removed by Filter: 9000
         WAL: records=85 bytes=8554
(6 rows)

-- 3. An index on qty itself.
CREATE INDEX stock_qty ON stock (qty);
CREATE INDEX
EXPLAIN (ANALYZE, WAL, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS OFF) UPDATE stock SET qty = qty - 1 WHERE id % 10 = 2;
                      QUERY PLAN                       
-------------------------------------------------------
 Update on stock (actual rows=0.00 loops=1)
   WAL: records=6260 bytes=461184
   ->  Seq Scan on stock (actual rows=1000.00 loops=1)
         Filter: ((id % 10) = 2)
         Rows Removed by Filter: 9000
         WAL: records=85 bytes=8554
(6 rows)

SELECT pg_stat_force_next_flush();
 pg_stat_force_next_flush 
--------------------------
 
(1 row)

SELECT n_tup_upd AS updates, n_tup_hot_upd AS hot_updates FROM pg_stat_user_tables WHERE relname = 'stock';
 updates | hot_updates 
---------+-------------
    3000 |        2000
(1 row)

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

writes.sql

-- 10,000 stock rows with room left on every page (fillfactor 85), and a quantity that changes all day.
CREATE TABLE stock (id int PRIMARY KEY, sku text NOT NULL, shelf int NOT NULL, supplier int NOT NULL,
                    qty int NOT NULL) WITH (fillfactor = 85);
INSERT INTO stock SELECT n, 'SKU-' || n, n % 400, n % 60, 100 FROM generate_series(1, 10000) AS n;
VACUUM ANALYZE stock;
-- 1. Only the primary key: an update of qty changes no indexed column.
EXPLAIN (ANALYZE, WAL, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS OFF) UPDATE stock SET qty = qty - 1 WHERE id % 10 = 0;
-- 2. Three more indexes, none on qty.
CREATE INDEX stock_sku ON stock (sku);
CREATE INDEX stock_shelf ON stock (shelf);
CREATE INDEX stock_supplier ON stock (supplier);
EXPLAIN (ANALYZE, WAL, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS OFF) UPDATE stock SET qty = qty - 1 WHERE id % 10 = 1;
-- 3. An index on qty itself.
CREATE INDEX stock_qty ON stock (qty);
EXPLAIN (ANALYZE, WAL, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS OFF) UPDATE stock SET qty = qty - 1 WHERE id % 10 = 2;
SELECT pg_stat_force_next_flush();
SELECT n_tup_upd AS updates, n_tup_hot_upd AS hot_updates FROM pg_stat_user_tables WHERE relname = 'stock';

Step through the three updates and what each one wrote:

Interactive · step by step One update of qty, with and without an index on it Step through the lesson's three updates of 1,000 stock rows and count what each one writes to the table, to the indexes and to the WAL.
One update of qty, with and without an index on it, step 5 of 5. Showing UPDATE stock (461,184 WAL bytes), Heap page (1,000 HOT versions), stock_pkey, Three more indexes (unchanged by qty updates), stock_qty. In focus: UPDATE stock.UPDATE stock461,184 WAL bytesHeap page1,000 HOT versionsstock_pkeyon idThree more indexesunchanged by qty updatesstock_qtyon qty

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

Every step as text
  1. A heap-only update. With only the primary key, changing qty touches no indexed column, and each page has room left by fillfactor 85. Each new row version stays on its page and is linked from the old one, so no index changes.
  2. The WAL of a HOT update. EXPLAIN (ANALYZE, WAL) counted 1,000 WAL records and 71,688 bytes for the 1,000 rows, about 72 bytes each.
  3. Three more indexes, still HOT. Indexes on sku, shelf and supplier do not contain qty, so the next 1,000 updates stay heap-only. The update wrote 1,085 records; 85 came from the scan pruning the dead versions of the first update.
  4. Index the column you update. With an index on qty, every new version needs a new entry in that index, and because the update is no longer HOT, a new entry in every other index as well.
  5. Six times the WAL. The same 1,000 updates now wrote 6,260 records and 461,184 bytes, 6.4 times the heap-only case. pg_stat_user_tables counted 2,000 HOT updates out of 3,000.

With only the primary key the updates were HOT: 1,000 WAL records, 71,688 bytes. Three more indexes on columns the update does not touch changed almost nothing, 1,085 records, of which 85 were the scan tidying the previous update’s dead versions. One index on qty itself made every update non-HOT, so each new version needed an entry in all five indexes: 6,260 records and 461,184 bytes. pg_stat_user_tables counted 2,000 HOT updates out of 3,000.

Key idea

An index costs its table on every insert, and on every update that changes one of its columns; index a column that changes often and every update of that column loses HOT, paying for all of the table’s indexes at once.

Checkpoint

Check: The write cost of indexes

  1. Question 1 of 2 Which conditions must hold for an UPDATE to be HOT, with no new index entries?

    Choose every answer that is right.

    Show the answer: Question 1 of 2

    Answer:

    • The new row version fits on the same heap page as the old one
    • No column of the table's indexes changes, BRIN indexes aside

    The manual names both conditions. pg_stat_user_tables.n_tup_hot_upd counts the updates that met them.

  2. Question 2 of 2 The 1,000 HOT updates wrote 71,688 bytes of WAL; with an index on qty the same updates wrote 461,184 bytes. By what factor did the WAL grow, to one decimal?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 6.4 times (anything from 6.35 to 6.45 counts)

    461,184 ÷ 71,688 ≈ 6.43. Each non-HOT update wrote a new row version and an entry in each of the five indexes.

Find unused, duplicate and invalid indexes before you drop any

Indexes accumulate: one for a report nobody runs, a second copy someone added by name, one left behind by a build that failed. The run creates such a set and asks one catalog query about all of them:

Scans, twins and validity of every index of orders

find_session.py

"""Index design on PostgreSQL 18.6: find.sql, unused, twin and invalid indexes of orders."""

from pg_session import Server

# The same plan on every run: autovacuum off, no parallel workers or JIT, and statistics from every row.
SETTINGS = {"autovacuum": "off", "max_parallel_workers_per_gather": 0, "jit": "off",
            "default_statistics_target": 1000, "timezone": "'UTC'",
            # one process builds each index, so the failed build always reports the same duplicate
            "max_parallel_maintenance_workers": 0}

with Server(settings=SETTINGS) as pg:
    pg.up()
    pg.psql("setup.sql", flags=("-q",))
    pg.psql("find.sql", errors=1)

Output

$ psql -X -q -f setup.sql

$ psql -X -a -f find.sql
-- Five indexes on orders: one used, one never used, one that duplicates another, and one left by a failed build.
CREATE INDEX orders_customer ON orders (customer_id);
CREATE INDEX
CREATE INDEX orders_customer_again ON orders (customer_id);
CREATE INDEX
CREATE INDEX orders_total ON orders (total);
CREATE INDEX
CREATE UNIQUE INDEX CONCURRENTLY orders_email_key ON orders (email);
psql:find.sql:5: ERROR:  could not create unique index "orders_email_key"
DETAIL:  Key (email)=(Customer1@Example.com) is duplicated.
SELECT count(*) FROM orders WHERE customer_id = 42;
 count 
-------
    20
(1 row)

SELECT pg_stat_force_next_flush();
 pg_stat_force_next_flush 
--------------------------
 
(1 row)

-- One catalog query: scans since the statistics were reset, duplicates by definition, and validity.
SELECT s.indexrelname AS index, s.idx_scan AS scans, i.indisvalid AS valid, i.indisunique AS is_unique,
       (SELECT string_agg(d.indexrelid::regclass::text, ', ') FROM pg_index AS d
        WHERE d.indrelid = i.indrelid AND d.indexrelid <> i.indexrelid AND d.indkey = i.indkey
          AND d.indclass = i.indclass AND d.indcollation = i.indcollation AND d.indoption = i.indoption
          AND coalesce(d.indpred::text, '') = coalesce(i.indpred::text, '')
          AND coalesce(d.indexprs::text, '') = coalesce(i.indexprs::text, '')) AS same_as,
       pg_size_pretty(pg_relation_size(s.indexrelid)) AS size
FROM pg_stat_user_indexes AS s JOIN pg_index AS i ON i.indexrelid = s.indexrelid
WHERE s.relname = 'orders' ORDER BY s.indexrelname;
         index         | scans | valid | is_unique |        same_as        |  size   
-----------------------+-------+-------+-----------+-----------------------+---------
 orders_customer       |     0 | t     | f         | orders_customer_again | 776 kB
 orders_customer_again |     1 | t     | f         | orders_customer       | 776 kB
 orders_email_key      |     0 | f     | t         |                       | 0 bytes
 orders_pkey           |     0 | t     | t         |                       | 2208 kB
 orders_total          |     0 | t     | f         |                       | 2224 kB
(5 rows)

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

find.sql

-- Five indexes on orders: one used, one never used, one that duplicates another, and one left by a failed build.
CREATE INDEX orders_customer ON orders (customer_id);
CREATE INDEX orders_customer_again ON orders (customer_id);
CREATE INDEX orders_total ON orders (total);
CREATE UNIQUE INDEX CONCURRENTLY orders_email_key ON orders (email);
SELECT count(*) FROM orders WHERE customer_id = 42;
SELECT pg_stat_force_next_flush();
-- One catalog query: scans since the statistics were reset, duplicates by definition, and validity.
SELECT s.indexrelname AS index, s.idx_scan AS scans, i.indisvalid AS valid, i.indisunique AS is_unique,
       (SELECT string_agg(d.indexrelid::regclass::text, ', ') FROM pg_index AS d
        WHERE d.indrelid = i.indrelid AND d.indexrelid <> i.indexrelid AND d.indkey = i.indkey
          AND d.indclass = i.indclass AND d.indcollation = i.indcollation AND d.indoption = i.indoption
          AND coalesce(d.indpred::text, '') = coalesce(i.indpred::text, '')
          AND coalesce(d.indexprs::text, '') = coalesce(i.indexprs::text, '')) AS same_as,
       pg_size_pretty(pg_relation_size(s.indexrelid)) AS size
FROM pg_stat_user_indexes AS s JOIN pg_index AS i ON i.indexrelid = s.indexrelid
WHERE s.relname = 'orders' ORDER BY s.indexrelname;

pg_stat_user_indexes.idx_scan counts the scans of each index since the statistics were last reset. pg_index gives the definition: indkey, indclass, indcollation and indoption for the columns, operator classes, collations and sort options, indpred for a predicate, indexprs for expressions, indisunique and indisvalid. The query found orders_customer and orders_customer_again with the same definition, 776 kB each, only one of them scanned; orders_email_key invalid after the failed unique build; and orders_pkey and orders_total with 0 scans. Step through what each finding means:

Interactive · stage by stage Before you drop an index Step through the lesson's five indexes of orders and see which check keeps each one, or lets it go.
Before you drop an index, step 6 of 6. Showing List the indexes, Valid?, Enforces a rule?, Has a twin? (drop orders_customer), Scanned in a full cycle?, Drop it. In focus: Drop it.no: a failed buildyes: keep itList the indexespg_stat_user_indexesValid?pg_index.indisvalidEnforces a rule?unique, primary keyHas a twin?drop orders_customerScanned in a full cycle?idx_scan, on every serverDrop itDROP INDEX CONCURRENTLY

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

Every step as text
  1. Five indexes on orders. The lesson's catalog query lists each index of orders with its scans since the statistics were reset, whether it is valid and unique, any twin with the same definition, and its size.
  2. An invalid index first. orders_email_key has indisvalid false, left by the failed CREATE UNIQUE INDEX CONCURRENTLY. The planner never uses it, but writes may still maintain it. Drop it, fix the duplicates, and build it again.
  3. A rule, not a search. orders_pkey had 0 scans but enforces the primary key. An index that is unique or backs a constraint stays, whatever idx_scan says.
  4. Twins. orders_customer and orders_customer_again have the same columns, operator classes, collations and sort options, and no predicate, 776 kB each. Only one of them was scanned; the other is pure write cost. Keep one.
  5. Never scanned. orders_total had 0 scans. That counts only on this server since the last reset, so check a whole business cycle and every replica, where reports may use it, before you decide.
  6. Drop without blocking. DROP INDEX CONCURRENTLY removes an index without blocking reads and writes of the table, and it can be recreated the same way if a forgotten report needed it.

Two cautions from the manual. Statistics are kept per server, so an index unused on the primary may serve every report on a replica. And a zero means nothing until the statistics cover a full business cycle, including the month-end jobs.

Checkpoint

Check: Unused, duplicate and invalid indexes

  1. Question 1 of 2 The catalog query showed idx_scan 0 for orders_pkey and orders_total. Which may you drop after checking further?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Possibly orders_total, after a full business cycle and every replica show no scans; never orders_pkey

    Statistics are kept per server, so a report on a replica may use orders_total even though the primary never does.

  2. Question 2 of 2 orders_customer and orders_customer_again have the same columns, operator classes, collations and options, and no predicate. What should you do?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Keep one and drop the other, which costs 776 kB and write work for nothing

    Compare indkey, indclass, indcollation, indoption, indpred and indexprs in pg_index to find twins, as the recorded query did.

Practise: trim the indexes of the shop

The questions use the recorded runs. Before you begin, write the partial index you would add for “unpaid orders older than a week” and the query text that would let the planner use it:

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 An index has WHERE status = 'pending'. Which query can the planner prove it covers?

    Choose one answer.

    Show the answer: Question 1 of 10

    Answer: WHERE customer_id = 7 AND status = 'pending'

    The planner's proof is simple and textual: repeat the index's condition in the query.

  2. Question 2 of 10 When is a partial index a poor choice?

    Choose one answer.

    Show the answer: Question 2 of 10

    Answer: When the queries reach the rows through parameters whose values decide the subset

    Check how the application sends the query: literals, or parameters planned generically.

  3. Question 3 of 10 Which expression can be indexed?

    Choose one answer.

    Show the answer: Question 3 of 10

    Answer: lower(email)

    CREATE INDEX accepts only immutable functions and operators in an index expression.

  4. Question 4 of 10 Besides speeding up the lookup, what else changed after the expression index and an ANALYZE?

    Choose one answer.

    Show the answer: Question 4 of 10

    Answer: The estimate for lower(email) = … went from 500 rows to 20, because ANALYZE gathers statistics on index expressions

    The recorded estimates were rows=500 before and rows=20 after, the real 20 orders of that customer.

  5. Question 5 of 10 Accounts are soft-deleted with deleted_at. Which index keeps e-mail addresses unique among accounts in use only, ignoring case?

    Choose one answer.

    Show the answer: Question 5 of 10

    Answer: CREATE UNIQUE INDEX ON accounts (lower(email)) WHERE deleted_at IS NULL

    Expressions and predicates combine: the index holds lower(email) of the live accounts and refuses a second one.

  6. Question 6 of 10 Why enforce one default address with an index rather than with a check in the application before each insert?

    Choose one answer.

    Show the answer: Question 6 of 10

    Answer: Two concurrent requests can both pass the application's check and both insert; the index refuses the second

    Rules that must hold for all writers belong in constraints and unique indexes.

  7. Question 7 of 10 Which writes add an entry to an ordinary, non-partial index?

    Choose one answer.

    Show the answer: Question 7 of 10

    Answer: Every insert, and every update that is not HOT

    This is why each extra index slows inserts, and why an index on a busy column slows updates the most.

  8. Question 8 of 10 A table's updates change only non-indexed columns, yet n_tup_hot_upd stays near 0. What should you check first?

    Choose one answer.

    Show the answer: Question 8 of 10

    Answer: Whether the pages have room for new versions; a lower fillfactor leaves space for HOT updates

    The run created stock with fillfactor 85 so that the 1,000 updates could stay on their pages.

  9. Question 9 of 10 pg_stat_user_indexes shows idx_scan 0 for an index after a week of statistics. What is still unknown?

    Choose one answer.

    Show the answer: Question 9 of 10

    Answer: Whether monthly or yearly jobs, or queries on replicas, use it

    Record the counts at the end of a full business cycle on every server before deciding.

  10. Question 10 of 10 What does indisvalid = false in pg_index mean for orders_email_key?

    Choose one answer.

    Show the answer: Question 10 of 10

    Answer: Its build failed part-way, so the planner ignores it; drop it, fix the cause and build it again

    The next lesson of this module rebuilds such indexes without blocking writes.

SQL Formatter Lay out the catalog query of unused and duplicate indexes before you adapt it.

Interview questions

Warm-up (fresher): What is a partial index, and when will PostgreSQL use it?

A partial index is built over the rows that satisfy a WHERE clause written into the index definition, for example CREATE INDEX ON orders (customer_id) WHERE status = ‘pending’. It is smaller than a full index and costs nothing for rows outside the predicate, so it suits queries that always target a small, well-defined subset. The planner uses it only when it can prove, from the query’s own WHERE clause, that every row the query needs satisfies the index’s predicate. A query that repeats the condition, such as status = ‘pending’ AND customer_id = 42, qualifies; a query with status = $1 in a generic plan does not, because the value is unknown when the plan is made. A partial unique index also enforces rules such as one default address per customer.

Two scenarios go further: a table whose writes slowed after indexes were added, and the decision to drop an index nobody seems to use. Open them for a first step and the follow-ups to prepare:

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

  1. Mid to senior

    After a sprint that added five indexes to a busy inventory table, its updates are slower, WAL volume has doubled and replicas lag at peak. Each index was added for a real query. How do you find which indexes cost the most, and what would you change?

    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 40 GB index on a payments table shows idx_scan = 0 in pg_stat_user_indexes on the primary. A colleague wants to drop it tonight to save disk and write time. What do you check first, and how do you drop it safely if it really is unused?

    Model answer, Pro. First step and follow-ups

Flashcards · 4 cards

Recall it, then check

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

  1. What is a partial index, and when will the planner use it?

    It holds only the rows of its predicate (16 kB vs 816 kB here). The planner uses it only when the query's own WHERE clause proves the predicate — which a generic $1 plan cannot.

  2. What does an expression index store and serve?

    The result of an immutable expression such as lower(email). It serves only queries that use the same expression, and gives ANALYZE statistics on that expression.

  3. When is an UPDATE a HOT update, and what do indexes cost writes?

    Inserts pay for every index. Updates are HOT (no new index entries) only when no indexed column changes and the new version fits on its page, so indexing a busy column multiplied the WAL of the same updates by 6.4.

  4. How do you find unused, duplicate or invalid indexes, and what to check first?

    pg_stat_user_indexes and pg_index. But unique indexes, replicas and a full business cycle must be checked before any index is dropped.

Key takeaways

  • A partial index holds only the rows of its predicate, 16 kB against 816 kB here, and the planner uses it only when the query’s own WHERE clause proves the predicate, which a generic $1 plan cannot.
  • An expression index stores the result of an immutable expression such as lower(email), serves only queries that use the same expression, and gives ANALYZE statistics on it.
  • A partial unique index enforces a rule on a subset of rows, such as one default address per customer, for every client at once.
  • Inserts pay for every index; updates are HOT, with no new index entries, only when no indexed column changes (BRIN aside) and the new version fits on its page, so indexing a busy column multiplied the WAL of the same updates by 6.4.
  • pg_stat_user_indexes and pg_index find unused, duplicate and invalid indexes, but unique indexes, replicas and a full business cycle must be checked before any index is dropped.

Terms in this lesson

expression index
An index on the result of an immutable expression, such as lower(email), instead of on a plain column. Only queries that use the same expression can use it, and ANALYZE gathers statistics on the expression.
HOT update
A heap-only tuple update: when no indexed column changes and the new row version fits on the same page, PostgreSQL links it from the old version and adds no index entries.
partial index
An index built only over the rows that satisfy a WHERE clause in its definition. The planner uses it when the query's own conditions prove that every row it needs is in the index.

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.