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

Building and checking indexes online

Create, rebuild and drop PostgreSQL 18 indexes without blocking writes, recover from a failed concurrent build, follow progress and check with amcheck.

  • 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

  • Create, rebuild and drop indexes with CONCURRENTLY and explain what each phase waits for
  • Recover from a failed concurrent build that left an invalid index
  • Follow an index build with pg_stat_progress_create_index
  • Verify index integrity with amcheck after crashes, bugs or collation changes

Before you start

What you’ll learn · Before you start

What you will learn

  • Create, rebuild and drop indexes with CONCURRENTLY and explain what each phase waits for
  • Recover from a failed concurrent build that left an invalid index
  • Follow an index build with pg_stat_progress_create_index
  • Verify index integrity with amcheck after crashes, bugs or collation changes

Before you start

On this page

A plain CREATE INDEX on a busy table holds a lock that makes every insert, update and delete wait until the build finishes, which on a large table can mean minutes of requests that hang. PostgreSQL can build, rebuild and drop indexes while writes continue, but those online forms wait for other transactions, can fail half-way and leave an invalid index behind, and an index can silently stop matching its table.

You will see the lock a normal build takes, watch a concurrent build wait for an open transaction, recover from a failed unique build, shrink a bloated index with REINDEX CONCURRENTLY, and use amcheck to prove that an index still agrees with its table.

CREATE INDEX blocks writes; CONCURRENTLY does not

Every statement takes a lock on the tables it uses, and lock modes conflict with each other according to a fixed table in the manual. The run builds an index inside a transaction, so that pg_locks can show the lock while it is held:

The lock of CREATE INDEX, and CONCURRENTLY inside a transaction

locks_session.py

"""Online index operations on PostgreSQL 18.6: locks.sql on the accounts of setup.sql."""

from pg_session import Server

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

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

Output

$ psql -X -q -f setup.sql

$ psql -X -a -f locks.sql
-- CREATE INDEX inside a transaction, so that pg_locks can show the lock it holds on the table.
BEGIN;
BEGIN
CREATE INDEX accounts_tier ON accounts (tier);
CREATE INDEX
SELECT relation::regclass AS locked, mode, granted FROM pg_locks
WHERE locktype = 'relation' AND relation = 'accounts'::regclass;
  locked  |   mode    | granted 
----------+-----------+---------
 accounts | ShareLock | t
(1 row)

ROLLBACK;
ROLLBACK
-- CONCURRENTLY cannot run inside a transaction block.
BEGIN;
BEGIN
CREATE INDEX CONCURRENTLY accounts_tier ON accounts (tier);
psql:locks.sql:9: ERROR:  CREATE INDEX CONCURRENTLY cannot run inside a transaction block
ROLLBACK;
ROLLBACK

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

locks.sql

-- CREATE INDEX inside a transaction, so that pg_locks can show the lock it holds on the table.
BEGIN;
CREATE INDEX accounts_tier ON accounts (tier);
SELECT relation::regclass AS locked, mode, granted FROM pg_locks
WHERE locktype = 'relation' AND relation = 'accounts'::regclass;
ROLLBACK;
-- CONCURRENTLY cannot run inside a transaction block.
BEGIN;
CREATE INDEX CONCURRENTLY accounts_tier ON accounts (tier);
ROLLBACK;

setup.sql

-- The accounts of this lesson: 100,000 rows, and e-mail addresses of which ten are used twice.
CREATE TABLE accounts (id int PRIMARY KEY, email text NOT NULL, tier int NOT NULL, tags text[] NOT NULL);
INSERT INTO accounts
SELECT n, 'user' || (CASE WHEN n > 99990 THEN n - 99990 ELSE n END) || '@example.com', n % 5, ARRAY['t' || n % 9]
FROM generate_series(1, 100000) AS n;
VACUUM ANALYZE accounts;

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)

CREATE INDEX held a ShareLock on accounts. ShareLock allows other readers but conflicts with the RowExclusiveLock that every INSERT, UPDATE and DELETE needs, so writers queue behind the build. A concurrent index build: CREATE INDEX CONCURRENTLY: a build that takes a lock writers do not wait for, scans the table twice and waits for older transactions, so that inserts, updates and deletes continue while it runs., CREATE INDEX CONCURRENTLY, takes ShareUpdateExclusiveLock instead, which does not conflict with writers. The price is in the second statement: it refused to run inside BEGIN, “CREATE INDEX CONCURRENTLY cannot run inside a transaction block”, because it commits several times along the way.

Choose the statement for the job and the table:

Interactive · answer and compare Which index statement fits the job? Answer two questions about the index work you have to do, and the table rules out the statements that would block the table or cannot do the job.
What must happen to the index?
Is the table in use by writers right now?

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

Five index statements against blocking, transactions and the work they do
Statement Writes continue Runs in a transaction Little extra work
CREATE INDEX Example: CREATE INDEX i ON t (c) Poor ShareLock: inserts, updates and deletes wait Good allowed inside BEGIN … COMMIT Good one scan of the table
CREATE INDEX CONCURRENTLY Example: CREATE INDEX CONCURRENTLY i ON t (c) Good ShareUpdateExclusiveLock lets them run Poor refused inside a transaction block Poor two scans, plus waits for older transactions
REINDEX Example: REINDEX INDEX i Poor blocks writes to the table while it rebuilds Mixed one index or table, yes; SCHEMA, DATABASE, SYSTEM, no Good one rebuild
REINDEX CONCURRENTLY Example: REINDEX INDEX CONCURRENTLY i Good builds a copy beside the old index and swaps them Poor refused inside a transaction block Poor two indexes exist for a while; more work, more space
DROP INDEX CONCURRENTLY Example: DROP INDEX CONCURRENTLY i Good removes the index without blocking reads and writes Poor refused inside a transaction block Mixed waits for transactions that use the index
How the answers decide
  • “What must happen to the index?” A new index: rules out REINDEX, REINDEX CONCURRENTLY and DROP INDEX CONCURRENTLY. Only the two forms of CREATE INDEX make a new index.
  • “What must happen to the index?” Rebuild a bloated or corrupt one: rules out CREATE INDEX, CREATE INDEX CONCURRENTLY and DROP INDEX CONCURRENTLY and adds a caveat to REINDEX CONCURRENTLY. REINDEX rebuilds in place; the concurrent form needs room for a second copy while it works.
  • “What must happen to the index?” Remove one: rules out CREATE INDEX, CREATE INDEX CONCURRENTLY, REINDEX and REINDEX CONCURRENTLY. DROP INDEX CONCURRENTLY removes an index without blocking the table, which a plain DROP INDEX would do.
  • “Is the table in use by writers right now?” Yes, writes must go on: rules out CREATE INDEX and REINDEX. Both block writes for the whole build; on a busy table use the CONCURRENTLY forms, outside a transaction block.
  • “Is the table in use by writers right now?” No, a short block is fine: adds a caveat to CREATE INDEX CONCURRENTLY and REINDEX CONCURRENTLY. With no writers to protect, the plain forms do less work and may run inside a transaction.

Checkpoint

Check: Locks of index builds

  1. Question 1 of 2 Which lock did a plain CREATE INDEX hold on accounts in the recorded run, and whom does it block?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: ShareLock, which blocks INSERT, UPDATE and DELETE but lets SELECT run

    Writers queue behind a plain build for its whole duration. CREATE INDEX CONCURRENTLY takes ShareUpdateExclusiveLock instead.

  2. Question 2 of 2 A migration tool wraps every step in BEGIN … COMMIT. What happens to its CREATE INDEX CONCURRENTLY step?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It fails with "cannot run inside a transaction block"; the step must run outside the transaction

    Migration tools usually have a way to mark a step as non-transactional for exactly this statement.

A concurrent build waits for older transactions

An index built concurrently must not miss a row written by a transaction that started before the index existed. So the build waits, more than once, for transactions that could write to the table without knowing about the new index. The run leaves one writer’s transaction open, starts a concurrent build in a second session, and watches from a third:

CREATE INDEX CONCURRENTLY waiting for an open transaction

progress_session.py

"""CREATE INDEX CONCURRENTLY waiting for a writer's open transaction, watched from a third session."""

import subprocess

from pg_session import BIN, Server

SETTINGS = {"autovacuum": "off", "max_parallel_workers_per_gather": 0, "jit": "off",
            "default_statistics_target": 1000, "timezone": "'UTC'", "max_parallel_maintenance_workers": 0}


def session(args, **kw):
    """A psql process of its own, as a second user at another terminal would run it."""
    return subprocess.Popen([str(BIN / "psql"), "-X", *args], env=pg.env, cwd=pg.base, text=True, **kw)


with Server(settings=SETTINGS) as pg:
    pg.up()
    pg.psql("setup.sql", flags=("-q",))
    # Session 1: a writer changes one account and leaves its transaction open.
    writer = session(["-q"], stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    writer.stdin.write("BEGIN;\nUPDATE accounts SET tier = tier WHERE id = 1;\n")
    writer.stdin.flush()
    pg.wait_for("SELECT count(*) FROM pg_stat_activity WHERE state = 'idle in transaction'", "1")
    print("-- session 1: BEGIN; UPDATE accounts SET tier = tier WHERE id = 1;   (its transaction stays open)")
    # Session 2: the concurrent build, which has to wait for that transaction to end.
    build = session(["-c", "CREATE INDEX CONCURRENTLY accounts_tier ON accounts (tier)"],
                    stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
    pg.wait_for("SELECT phase FROM pg_stat_progress_create_index", "waiting for writers before build")
    print("-- session 2: CREATE INDEX CONCURRENTLY accounts_tier ON accounts (tier);   (running)")
    print()
    pg.psql("progress.sql", note="session 3, while session 2 waits")
    writer.stdin.write("COMMIT;\n")
    writer.stdin.close()
    writer.wait(timeout=60)
    out, _ = build.communicate(timeout=120)
    print("-- session 1: COMMIT;   then session 2 finished and printed:")
    print(pg.clean(out).rstrip("\n"))
    print()
    pg.psql("done.sql")

Output

$ psql -X -q -f setup.sql

-- session 1: BEGIN; UPDATE accounts SET tier = tier WHERE id = 1;   (its transaction stays open)
-- session 2: CREATE INDEX CONCURRENTLY accounts_tier ON accounts (tier);   (running)

-- session 3, while session 2 waits
$ psql -X -a -f progress.sql
-- Asked while CREATE INDEX CONCURRENTLY runs in another session and one writer is still in its transaction.
SELECT phase, lockers_total, lockers_done FROM pg_stat_progress_create_index;
              phase               | lockers_total | lockers_done 
----------------------------------+---------------+--------------
 waiting for writers before build |             1 |            0
(1 row)

SELECT mode, granted FROM pg_locks WHERE locktype = 'relation' AND relation = 'accounts'::regclass ORDER BY mode;
           mode           | granted 
--------------------------+---------
 RowExclusiveLock         | t
 ShareUpdateExclusiveLock | t
(2 rows)

SELECT state, wait_event_type, wait_event, left(query, 60) AS query FROM pg_stat_activity
WHERE backend_type = 'client backend' AND pid <> pg_backend_pid() ORDER BY state;
        state        | wait_event_type | wait_event |                           query                            
---------------------+-----------------+------------+------------------------------------------------------------
 active              | Lock            | virtualxid | CREATE INDEX CONCURRENTLY accounts_tier ON accounts (tier)
 idle in transaction | Client          | ClientRead | UPDATE accounts SET tier = tier WHERE id = 1;
(2 rows)

-- session 1: COMMIT;   then session 2 finished and printed:
CREATE INDEX

$ psql -X -a -f done.sql
-- After the writer committed: the concurrent build finished, and the index is valid.
SELECT indexrelid::regclass AS index, indisvalid, indisready FROM pg_index
WHERE indrelid = 'accounts'::regclass ORDER BY 1;
     index     | indisvalid | indisready 
---------------+------------+------------
 accounts_pkey | t          | t
 accounts_tier | t          | t
(2 rows)

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

progress.sql

-- Asked while CREATE INDEX CONCURRENTLY runs in another session and one writer is still in its transaction.
SELECT phase, lockers_total, lockers_done FROM pg_stat_progress_create_index;
SELECT mode, granted FROM pg_locks WHERE locktype = 'relation' AND relation = 'accounts'::regclass ORDER BY mode;
SELECT state, wait_event_type, wait_event, left(query, 60) AS query FROM pg_stat_activity
WHERE backend_type = 'client backend' AND pid <> pg_backend_pid() ORDER BY state;

done.sql

-- After the writer committed: the concurrent build finished, and the index is valid.
SELECT indexrelid::regclass AS index, indisvalid, indisready FROM pg_index
WHERE indrelid = 'accounts'::regclass ORDER BY 1;

pg_stat_progress_create_index reported the phase waiting for writers before build, with lockers_total 1 and lockers_done 0: one transaction to wait for, none finished. pg_locks showed both sessions holding their locks on accounts at once, RowExclusiveLock and ShareUpdateExclusiveLock, and pg_stat_activity showed the build active but waiting, Lock / virtualxid, on session 1, which was idle in transaction. When session 1 committed, the build finished and pg_index showed the new index valid. Step through the whole build:

Interactive · message by message CREATE INDEX CONCURRENTLY beside a writer's open transaction Step through the lesson's concurrent build of accounts_tier and see what it locks, what it waits for, and when the index becomes usable.
CREATE INDEX CONCURRENTLY beside a writer's open transaction, step 6 of 6. Messages so far: Session 1 to accounts: RowExclusiveLock; Session 2 to accounts: ShareUpdateExclusiveLock; Session 1 to Session 2: COMMIT; Session 2 to accounts: validation scan.Session 1committedSession 2accounts_tier validaccounts100,000 rowsRowExclusiveLockShareUpdateExclusiveLockCOMMITvalidation scan

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

Every step as text
  1. A writer in a transaction. Session 1 updated account 1 and has not committed. It holds a RowExclusiveLock on accounts, the lock every INSERT, UPDATE and DELETE takes.
  2. A lock that lets writes go on. The concurrent build takes ShareUpdateExclusiveLock, which does not conflict with writers, and records the new index in the catalogs as not yet ready or valid.
  3. Waiting for writers. The build may not start until every transaction that could write to the table without knowing about the new index has ended. pg_stat_progress_create_index showed waiting for writers before build, lockers_total 1, lockers_done 0.
  4. The writer commits. Session 1 commits. The build continues at once with its first scan of the table, while new writes keep arriving and are noted in the index from then on.
  5. A second scan. After waiting for writers once more, the build scans the table again and adds the rows that changed during the first scan. This is why the manual says a concurrent build does more work and takes longer.
  6. Valid. Once older snapshots are gone, the index is marked valid and the planner may use it. The recorded run printed CREATE INDEX, and pg_index showed accounts_tier with indisvalid true.

An idle transaction can stall a build for hours

A concurrent build waits for every transaction that could matter, including one that a forgotten client left open. Watch pg_stat_progress_create_index during builds, and set idle_in_transaction_session_timeout for applications so that abandoned transactions end by themselves.

Checkpoint

Check: Waits and progress of a concurrent build

  1. Question 1 of 2 pg_stat_progress_create_index shows phase waiting for writers before build, lockers_total 1, lockers_done 0, for an hour. What is the build waiting for?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: One transaction that could write to the table and started before the build, such as a session idle in transaction

    Find the session with pg_stat_activity, where it shows idle in transaction, and end its transaction or the session.

  2. Question 2 of 2 Which of these did the recorded third session see while the build waited?

    Choose every answer that is right.

    Show the answer: Question 2 of 2

    Answer:

    • Both RowExclusiveLock and ShareUpdateExclusiveLock granted on accounts at the same time
    • The build active with wait event Lock / virtualxid
    • Session 1 idle in transaction

    pg_stat_progress_create_index, pg_locks and pg_stat_activity together show what a build is doing and why it waits.

A failed concurrent build leaves an invalid index

A plain CREATE INDEX that fails rolls back and leaves nothing. A concurrent one has already committed its catalog entry by the time it fails, so the index stays, marked as an invalid index: An index whose pg_index.indisvalid is false, usually left by a concurrent build or rebuild that failed. The planner never uses it; it is dropped and built again.. The run tries a unique index on e-mail addresses, ten of which are used twice:

A failed unique build, cleaned up and done again

invalid_session.py

"""Online index operations on PostgreSQL 18.6: invalid.sql on the accounts of setup.sql."""

from pg_session import Server

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

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

Output

$ psql -X -q -f setup.sql

$ psql -X -a -f invalid.sql
-- A unique index on the e-mail addresses, built concurrently: ten addresses are duplicated.
CREATE UNIQUE INDEX CONCURRENTLY accounts_email_key ON accounts (email);
psql:invalid.sql:2: ERROR:  could not create unique index "accounts_email_key"
DETAIL:  Key (email)=(user5@example.com) is duplicated.
SELECT indexrelid::regclass AS index, indisvalid, indisready, pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_index WHERE indrelid = 'accounts'::regclass ORDER BY 1;
       index        | indisvalid | indisready |  size   
--------------------+------------+------------+---------
 accounts_pkey      | t          | t          | 2208 kB
 accounts_email_key | f          | f          | 0 bytes
(2 rows)

-- Remove the failed index without blocking the table, fix the data, and build it again.
DROP INDEX CONCURRENTLY accounts_email_key;
DROP INDEX
UPDATE accounts SET email = 'user' || id || '@example.com' WHERE id > 99990;
UPDATE 10
CREATE UNIQUE INDEX CONCURRENTLY accounts_email_key ON accounts (email);
CREATE INDEX
SELECT indexrelid::regclass AS index, indisvalid, indisready, pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_index WHERE indrelid = 'accounts'::regclass ORDER BY 1;
       index        | indisvalid | indisready |  size   
--------------------+------------+------------+---------
 accounts_pkey      | t          | t          | 2208 kB
 accounts_email_key | t          | t          | 3992 kB
(2 rows)

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

invalid.sql

-- A unique index on the e-mail addresses, built concurrently: ten addresses are duplicated.
CREATE UNIQUE INDEX CONCURRENTLY accounts_email_key ON accounts (email);
SELECT indexrelid::regclass AS index, indisvalid, indisready, pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_index WHERE indrelid = 'accounts'::regclass ORDER BY 1;
-- Remove the failed index without blocking the table, fix the data, and build it again.
DROP INDEX CONCURRENTLY accounts_email_key;
UPDATE accounts SET email = 'user' || id || '@example.com' WHERE id > 99990;
CREATE UNIQUE INDEX CONCURRENTLY accounts_email_key ON accounts (email);
SELECT indexrelid::regclass AS index, indisvalid, indisready, pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_index WHERE indrelid = 'accounts'::regclass ORDER BY 1;

The build failed with “could not create unique index”, Key (email)=(user5@example.com) is duplicated, and left accounts_email_key with indisvalid and indisready false and 0 bytes. The planner ignores an invalid index. This one failed while it was first being built, before it was ready, so writes skip it too; one that fails in a later phase is ready, and then, as the manual warns, it adds work to every write until it is dropped. Either way a retry with the same name fails, because the name is taken. The recovery is three steps: DROP INDEX CONCURRENTLY, fix the duplicates, build again. The second build succeeded, a valid index of 3,992 kB.

Find invalid indexes after any failed build or interrupted deployment with SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid; the practice section turns that into a script.

Checkpoint

Check: Invalid indexes after a failed build

  1. Question 1 of 2 After CREATE UNIQUE INDEX CONCURRENTLY failed on duplicate e-mails, what did pg_index show for accounts_email_key?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: indisvalid false and indisready false, with 0 bytes

    An invalid index is ignored by the planner, adds work to every write once indisready is true, and blocks its name until it is dropped.

  2. Question 2 of 2 Which order of steps repaired the failed unique build?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: DROP INDEX CONCURRENTLY, fix the duplicate rows, then CREATE UNIQUE INDEX CONCURRENTLY again

    The second build succeeded and produced a valid index of 3,992 kB.

REINDEX CONCURRENTLY rebuilds an index while writes go on

Deleting rows does not shrink an index: VACUUM frees the entries for reuse, but the file keeps its pages. The run builds an index on 100,000 e-mail addresses, deletes 90 % of the rows, vacuums and rebuilds:

An index before and after REINDEX CONCURRENTLY

reindex_session.py

"""Online index operations on PostgreSQL 18.6: reindex.sql on the accounts of setup.sql."""

from pg_session import Server

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

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

Output

$ psql -X -q -f setup.sql

$ psql -X -a -f reindex.sql
-- An index after 90 % of its rows are deleted and vacuumed: the pages stay allocated.
CREATE INDEX accounts_email ON accounts (email);
CREATE INDEX
SELECT pg_size_pretty(pg_relation_size('accounts_email')) AS index_size;
 index_size 
------------
 3992 kB
(1 row)

DELETE FROM accounts WHERE id % 10 <> 0;
DELETE 90000
VACUUM accounts;
VACUUM
SELECT pg_size_pretty(pg_relation_size('accounts_email')) AS after_delete_and_vacuum;
 after_delete_and_vacuum 
-------------------------
 3992 kB
(1 row)

-- REINDEX CONCURRENTLY builds a new copy beside the old one and swaps them, while writes go on.
REINDEX INDEX CONCURRENTLY accounts_email;
REINDEX
SELECT pg_size_pretty(pg_relation_size('accounts_email')) AS after_reindex;
 after_reindex 
---------------
 416 kB
(1 row)

-- Index builds sort with maintenance_work_mem, not work_mem.
SHOW maintenance_work_mem;
 maintenance_work_mem 
----------------------
 64MB
(1 row)

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

reindex.sql

-- An index after 90 % of its rows are deleted and vacuumed: the pages stay allocated.
CREATE INDEX accounts_email ON accounts (email);
SELECT pg_size_pretty(pg_relation_size('accounts_email')) AS index_size;
DELETE FROM accounts WHERE id % 10 <> 0;
VACUUM accounts;
SELECT pg_size_pretty(pg_relation_size('accounts_email')) AS after_delete_and_vacuum;
-- REINDEX CONCURRENTLY builds a new copy beside the old one and swaps them, while writes go on.
REINDEX INDEX CONCURRENTLY accounts_email;
SELECT pg_size_pretty(pg_relation_size('accounts_email')) AS after_reindex;
-- Index builds sort with maintenance_work_mem, not work_mem.
SHOW maintenance_work_mem;

The index was 3,992 kB before the delete and still 3,992 kB after DELETE and VACUUM. REINDEX INDEX CONCURRENTLY built a new copy beside it, swapped them and dropped the old one: 416 kB, about a tenth. Like a concurrent build, it does two scans and waits for older transactions, needs room for both copies while it runs, and cannot run inside a transaction block; if it fails, the leftover copy has a name ending in _ccnew and is dropped like any invalid index.

Index builds sort with maintenance_work_mem, 64MB by default, and can use max_parallel_maintenance_workers helpers, 2 by default, for B-tree, BRIN and, new in PostgreSQL 18, GIN indexes. Raise the memory in the session that runs a large build rather than for the server.

Checkpoint

Check: Rebuilding with REINDEX CONCURRENTLY

  1. Question 1 of 2 An index of 3,992 kB stayed 3,992 kB after 90 % of the rows were deleted and vacuumed, and REINDEX CONCURRENTLY made it 416 kB. What share of its size, in per cent rounded to the nearest whole number, did the rebuild keep?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 10 % (anything from 9.5 to 10.5 counts)

    416 ÷ 3,992 ≈ 0.104, about 10 %: the size of the 10,000 rows that were left, built compactly.

  2. Question 2 of 2 Why did DELETE and VACUUM leave the index at 3,992 kB?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: VACUUM frees index entries for reuse but does not give the pages back to the file

    REINDEX CONCURRENTLY rebuilt it beside the old copy and swapped them while writes could continue.

amcheck proves that an index still matches its table

An index is a promise that its order and its entries match the table under the index’s comparison rules. If those rules change, through a bug, a crash on faulty storage, or a new operating-system collation library that sorts some text differently, the index can return wrong answers without any error. The amcheck extension verifies the promise:

amcheck on healthy indexes, and on an index whose function changed

amcheck_session.py

"""Online index operations on PostgreSQL 18.6: amcheck.sql on the accounts of setup.sql."""

from pg_session import Server

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

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

Output

$ psql -X -q -f setup.sql

$ psql -X -a -f amcheck.sql
-- amcheck: structural checks of B-tree and GIN indexes and of the table itself.
CREATE EXTENSION amcheck;
CREATE EXTENSION
CREATE INDEX accounts_email ON accounts (email);
CREATE INDEX
CREATE INDEX accounts_tags ON accounts USING gin (tags);
CREATE INDEX
SELECT bt_index_check('accounts_email', heapallindexed => true) AS email_index;
 email_index 
-------------
 
(1 row)

SELECT bt_index_parent_check('accounts_pkey', heapallindexed => true) AS primary_key;
 primary_key 
-------------
 
(1 row)

SELECT gin_index_check('accounts_tags') AS tags_index;
 tags_index 
------------
 
(1 row)

SELECT count(*) AS heap_problems FROM verify_heapam('accounts');
 heap_problems 
---------------
             0
(1 row)

-- An index on a function's results, then the function is changed: the index no longer matches the table.
CREATE FUNCTION tier_label(int) RETURNS text IMMUTABLE LANGUAGE sql AS $$ SELECT 'tier ' || $1 $$;
CREATE FUNCTION
CREATE INDEX accounts_tier_label ON accounts (tier_label(tier));
CREATE INDEX
CREATE OR REPLACE FUNCTION tier_label(int) RETURNS text IMMUTABLE LANGUAGE sql AS $$ SELECT 'level ' || $1 $$;
CREATE FUNCTION
SELECT count(*) AS found_by_index FROM accounts WHERE tier_label(tier) = 'level 3';
 found_by_index 
----------------
              0
(1 row)

SELECT bt_index_check('accounts_tier_label', heapallindexed => true);
psql:amcheck.sql:14: ERROR:  heap tuple (0,1) from table "accounts" lacks matching index tuple within index "accounts_tier_label"
HINT:  Retrying verification using the function bt_index_parent_check() might provide a more specific error.
REINDEX INDEX accounts_tier_label;
REINDEX
SELECT bt_index_check('accounts_tier_label', heapallindexed => true) AS after_reindex;
 after_reindex 
---------------
 
(1 row)

SELECT count(*) AS found_by_index FROM accounts WHERE tier_label(tier) = 'level 3';
 found_by_index 
----------------
          20000
(1 row)

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

amcheck.sql

-- amcheck: structural checks of B-tree and GIN indexes and of the table itself.
CREATE EXTENSION amcheck;
CREATE INDEX accounts_email ON accounts (email);
CREATE INDEX accounts_tags ON accounts USING gin (tags);
SELECT bt_index_check('accounts_email', heapallindexed => true) AS email_index;
SELECT bt_index_parent_check('accounts_pkey', heapallindexed => true) AS primary_key;
SELECT gin_index_check('accounts_tags') AS tags_index;
SELECT count(*) AS heap_problems FROM verify_heapam('accounts');
-- An index on a function's results, then the function is changed: the index no longer matches the table.
CREATE FUNCTION tier_label(int) RETURNS text IMMUTABLE LANGUAGE sql AS $$ SELECT 'tier ' || $1 $$;
CREATE INDEX accounts_tier_label ON accounts (tier_label(tier));
CREATE OR REPLACE FUNCTION tier_label(int) RETURNS text IMMUTABLE LANGUAGE sql AS $$ SELECT 'level ' || $1 $$;
SELECT count(*) AS found_by_index FROM accounts WHERE tier_label(tier) = 'level 3';
SELECT bt_index_check('accounts_tier_label', heapallindexed => true);
REINDEX INDEX accounts_tier_label;
SELECT bt_index_check('accounts_tier_label', heapallindexed => true) AS after_reindex;
SELECT count(*) AS found_by_index FROM accounts WHERE tier_label(tier) = 'level 3';

On healthy indexes every check returned an empty result: bt_index_check with heapallindexed on the e-mail index, bt_index_parent_check on the primary key, gin_index_check, new in PostgreSQL 18, on the GIN index of tags, and 0 problems from verify_heapam on the table. Then the run reproduced the failure on purpose: an index on tier_label(tier), after which the function was changed to return ‘level’ instead of ‘tier’. A query for ‘level 3’ found 0 rows through the index, although 20,000 rows matched, and bt_index_check reported “heap tuple (0,1) from table “accounts” lacks matching index tuple”. After REINDEX the check passed and the query found 20,000.

Key idea

amcheck compares an index with its own rules and its table; run it after a crash, a storage fault or an upgrade of the operating system’s collation library, and rebuild any index it reports.

bt_index_check takes only the lock of a plain SELECT, so it suits routine checks in production; bt_index_parent_check is more thorough but blocks writes while it runs. The release notes of PostgreSQL 18.6 also ask users of some btree_gist and ltree indexes to reindex them, a reminder to read the notes of every minor release.

Checkpoint

Check: Checking indexes with amcheck

  1. Question 1 of 2 What does bt_index_check return for a healthy index?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: An empty result with no error; problems are raised as errors

    verify_heapam differs: it returns rows, one per problem, and the recorded run counted 0 of them.

  2. Question 2 of 2 After tier_label was redefined, a query found 0 rows for 'level 3' through the index, though 20,000 rows matched. What does this reproduce, and what fixed it?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: An index whose comparison rules changed under it, like an operating-system collation upgrade; REINDEX fixed it

    An immutable function must really be immutable; when it, or a collation, changes, every index that depends on it must be rebuilt.

Practise: write the runbook for a failed build

Before the questions, write the query that lists every invalid index with its definition (pg_get_indexdef) and generates the DROP INDEX CONCURRENTLY and CREATE INDEX CONCURRENTLY statements to repair it. Run it as an ordinary query, so that you read the statements before anything runs them. The questions then test the decisions behind each step:

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 Why can writes continue during CREATE INDEX CONCURRENTLY?

    Choose one answer.

    Show the answer: Question 1 of 10

    Answer: Its ShareUpdateExclusiveLock does not conflict with the RowExclusiveLock that writers take

    The manual's table of lock conflicts decides who waits for whom.

  2. Question 2 of 10 A small table that is written once a night needs a new index during the day. Which statement is the better choice?

    Choose one answer.

    Show the answer: Question 2 of 10

    Answer: A plain CREATE INDEX, since no writers are waiting and it does less work, inside a transaction if wanted

    Choose by the table's writers: concurrent forms for busy tables, plain forms when a short block is harmless.

  3. Question 3 of 10 Which setting stops forgotten open transactions from stalling concurrent builds?

    Choose one answer.

    Show the answer: Question 3 of 10

    Answer: idle_in_transaction_session_timeout, which ends sessions that stay idle inside a transaction too long

    Set it for application roles, with a value longer than any legitimate pause inside a transaction.

  4. Question 4 of 10 According to the manual, why does a concurrent build take longer than a plain one?

    Choose one answer.

    Show the answer: Question 4 of 10

    Answer: It scans the table twice and waits for existing transactions that could use or modify the index

    The extra time is the price of letting writes continue.

  5. Question 5 of 10 Which query lists invalid indexes?

    Choose one answer.

    Show the answer: Question 5 of 10

    Answer: SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid

    psql's \d also prints INVALID next to such an index.

  6. Question 6 of 10 A REINDEX INDEX CONCURRENTLY was interrupted, and \d shows an INVALID index named orders_customer_idx_ccnew. What do you do?

    Choose one answer.

    Show the answer: Question 6 of 10

    Answer: Drop orders_customer_idx_ccnew, the transient copy, and run REINDEX CONCURRENTLY again

    A leftover named _ccold is the old index after a successful swap; it can simply be dropped.

  7. Question 7 of 10 A large index build is slow and spills its sort. Which setting gives it more memory, and where should you raise it?

    Choose one answer.

    Show the answer: Question 7 of 10

    Answer: maintenance_work_mem, in the session that runs the build

    max_parallel_maintenance_workers, 2 by default, adds helper processes for B-tree, BRIN and, since PostgreSQL 18, GIN builds.

  8. Question 8 of 10 What does a plain REINDEX INDEX block, and what does the concurrent form need instead?

    Choose one answer.

    Show the answer: Question 8 of 10

    Answer: Plain REINDEX blocks writes to the table and reads that use the index; the concurrent form needs room for two copies and more time

    The recorded rebuild shrank the index to 416 kB while the table stayed usable.

  9. Question 9 of 10 What does heapallindexed => true add to bt_index_check?

    Choose one answer.

    Show the answer: Question 9 of 10

    Answer: It checks that every row of the table has its entry in the index

    Without the option, bt_index_check verifies the index's own structure and order.

  10. Question 10 of 10 The operating system of a database server is upgraded, including its C library. Which text indexes are at risk, and what do you run?

    Choose one answer.

    Show the answer: Question 10 of 10

    Answer: Indexes on text columns that use libc collations; check them with bt_index_check and rebuild any that fail

    The builtin locale provider of the cluster-setup lesson avoids this dependency on the operating system.

Interview questions

Warm-up (fresher to 2–5 years): What does CREATE INDEX CONCURRENTLY do differently from CREATE INDEX, and what does it cost?

A plain CREATE INDEX takes a ShareLock on the table, which allows reads but makes every insert, update and delete wait until the build ends. CREATE INDEX CONCURRENTLY takes ShareUpdateExclusiveLock, which lets writes continue. To build a correct index while rows change, it scans the table twice and waits for transactions that could write to the table or still need older snapshots, so it does more work and takes longer, and an open transaction can make it wait. It cannot run inside a transaction block. If it fails, for example on duplicate values in a unique index, it leaves an invalid index that the planner ignores and that must be dropped, with DROP INDEX CONCURRENTLY, before the build is retried.

Two questions go further: a concurrent build that has not finished after six hours, and indexes that may be damaged by an operating-system upgrade. The module’s question page holds the opening move of each and the follow-ups that come after it:

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

  1. Mid to senior

    A CREATE INDEX CONCURRENTLY on a 300 GB table started six hours ago and still has not finished, while the table's writes look normal. How do you see what it is doing, and what are your options?

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

  2. Senior to staff

    After an operating-system upgrade on a replica that was promoted, some customer searches by name return nothing, though the rows exist. Explain the likely cause, how you would prove it, and how you would repair the cluster with the least downtime.

    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. How does CREATE INDEX CONCURRENTLY differ from CREATE INDEX?

    CREATE INDEX holds a ShareLock that makes writers wait. CREATE INDEX CONCURRENTLY takes a ShareUpdateExclusiveLock, lets writes continue, and cannot run inside a transaction block.

  2. What can stall a concurrent index build?

    Old transactions. The build makes two passes over the table and, more than once, waits until older transactions that might write to it have ended, so a client that leaves a transaction open and idle can hold it up for hours. pg_stat_progress_create_index shows the phase and how many lockers are done.

  3. What does a failed concurrent build leave behind, and how do you clean up?

    The index stays, marked invalid (indisvalid is false): the planner ignores it, and a retry under the same name fails. Recover in three steps: DROP INDEX CONCURRENTLY, fix the cause (such as duplicate keys), then build it again.

  4. What do REINDEX CONCURRENTLY and amcheck do?

    REINDEX CONCURRENTLY rebuilds a bloated or suspect index beside the old one and swaps them (3,992 kB to 416 kB here). amcheck verifies B-tree and, since PostgreSQL 18, GIN indexes against their own rules and their tables.

Key takeaways

  • CREATE INDEX holds a ShareLock that makes writers wait; CREATE INDEX CONCURRENTLY takes ShareUpdateExclusiveLock, lets writes continue and cannot run inside a transaction block.
  • A concurrent build scans the table twice and waits for older transactions; pg_stat_progress_create_index shows its phase and the lockers it waits for, and an idle transaction can stall it.
  • A failed concurrent build leaves an invalid index, indisvalid false, which is dropped with DROP INDEX CONCURRENTLY before the cause is fixed and the build retried.
  • REINDEX CONCURRENTLY rebuilds a bloated or suspect index beside the old one and swaps them, shrinking the lesson’s index from 3,992 kB to 416 kB.
  • amcheck checks B-tree and, since PostgreSQL 18, GIN indexes against their own rules and their tables, which is how an index that silently stopped matching is found before it returns wrong answers.

Terms in this lesson

concurrent index build
CREATE INDEX CONCURRENTLY: a build that takes a lock writers do not wait for, scans the table twice and waits for older transactions, so that inserts, updates and deletes continue while it runs.
invalid index
An index whose pg_index.indisvalid is false, usually left by a concurrent build or rebuild that failed. The planner never uses it; it is dropped and built again.

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.