Skip to content

Technologies

PostgreSQL

How PostgreSQL works inside, what each of its features costs, and how far one PostgreSQL server goes before a design needs something else.

Concise article — AI can help

Copy this prompt and paste it into your AI chat:

Help me study this article for practical system design.
Article: https://tech0ver.github.io/sysdes/technologies/postgresql.md
Ask my goal and preferred depth: a quick recap, a walkthrough, or a deeper discussion. Use the article examples, flag your additions, and let me set the pace.

Or give your AI the article link with your own prompt:

PostgreSQL is an open-source relational database with SQLStructured Query LanguageThe standard language for querying and changing data in relational databases. A query says what data it needs, and the database chooses how to get it, such as which indexes to use.Read more: Wikipedia: SQL, transactionsTransactionA group of reads and writes that the database applies as one unit. Either all of its changes happen or none do, and concurrent transactions do not see each other's half-done work.Read more: Transactions: Changing several rows at once, constraintsConstraintA rule that the database checks on every write, such as a unique key or a foreign key. The database rejects a write that breaks the rule, so a bug in one service cannot break it.Read more: Databases: Storing each fact once, and extensions, and many systems keep their data, their rules, and often their queue, search, and cacheCacheA copy of data or results that is faster to read than the original and can be rebuilt from it. It cuts latency and load on the source while it answers most reads. Losing it makes a system slower but not wrong.Read more: Caching: When a cache pays off in it. To defend it in a design, know what each feature costs on one server, what goes wrong as load grows, and which signs say that part of the work must move to another system.

A home rental service, like Airbnb, starts on one PostgreSQL server. Hosts list homes, guests search and book, and a dozen app servers send queries to the database at the same time. To size this server and to know what breaks first, you need a picture of what runs inside it.

PostgreSQL is a group of processes on one machine. The postmaster process listens for connections and starts a separate operating system process, a backend, for each one. A backend serves one client connection until it closes. An error in a query aborts only that query’s transaction, but all backends share the CPU, the disks, memory, and locksLockA mark that reserves a row or a range of rows for one transaction and makes conflicting transactions wait. A write takes an exclusive lock on its row until the transaction ends, and readers can share a shared lock. Locks prevent conflicting writes but cause waits and deadlocks.Read more: Transactions: Seeing unfinished changes, so one heavy query still slows the others. Each connection costs a whole process, and opening one is slow compared with a short query. So app servers keep connections open in a connection poolConnection poolA set of open connections that a client keeps and reuses. Opening a connection is slow, so services keep a pool of them to databases and other services. The pool size also limits how much load one client can put on a server.Read more: Networking: Securing the connection, and the number of connections becomes a limit of its own (more on many connections).

Backends also share memory. If the operating system kills one of them, for example when memory runs out, the postmaster can no longer trust that shared memory: it ends every backend and recovers the database from its log. One failed process interrupts all clients for a while.

The largest part of that shared memory is shared buffers: a cache of 8 KB pages of tables and indexesIndexExtra data, built from the records, that finds a record without a full scan. An index speeds up reads, but it takes space and every write must update it too. So a database indexes only what its queries need.Read more: Storage engines: Finding a record fast. A backend reads a page from shared buffers or loads it from a data file. A change goes to the page in memory, and the page is written back to the file later. PostgreSQL reads its files through the operating system, so a page can also sit in the OS page cachePage cacheMemory where the operating system keeps recently read and written parts of files. Reads of hot data skip the disk. Written data waits there before it reaches the disk, so a power loss can erase it until a flush.Read more: Storage engines: Surviving a crash. A miss in shared buffers is not always a disk read, and a hot page can take memory twice.

Where a backend finds a page A backend reads three pages of a table. Page 7 is in shared buffers, so no file is read. Page 12 is missing from shared buffers but sits in the OS page cache, so the file read takes no disk access. Page 31 is in neither and is read from the disk. Page 7 is held in both shared buffers and the OS page cache, so it takes memory twice. Backend Shared buffers PostgreSQL memory OS page cache kernel memory Data file disk page 7 page 7 page 12 page 7 page 12 page 31 MISS MISS MISS

The default size of shared buffers is 128 MB, so that PostgreSQL starts on a small machine shared with other programs. On a dedicated server, start at about a quarter of RAMRandom-access memoryThe main memory of a server: fast, but lost on restart and much more expensive than disk. Data and indexes that fit in RAM are read without disk access.Read more: Wikipedia: Random-access memory and leave the rest to the OS cache and to the memory that queries take for sorts and hashes. More memory helps only while the data that queries touch most does not yet fit. In load tests on a 16 GB server, raising shared_buffers from 3 GB to 12 GB and work_mem from 128 MB to 2 GB gave no more throughput. work_mem is the base memory budget of each sort or hash step in each query (more on query memory), and with 1,000 clients the larger settings made the server run out of memory (Angelakos).

Background processes do the rest of the work:

  • The WALWrite-ahead logA log on disk where changes are written before the data files they change. After a crash, the database replays it to rebuild the memtable of an LSM-tree or to repair half-written pages of a B-tree. A write is confirmed once its log record is flushed.Read more: Storage engines: Sorting writes in memory writer and the checkpointer flush the log of changes and write changed pages to the data files.
  • The background writer writes changed pages ahead of time, so a backend rarely has to write one before it can load another.
  • Autovacuum workers clean up old versions of rows and refresh the statistics that the query planner uses (more on vacuuming).
  • WAL senders stream the log to standbys, copies of the server that serve reads and can take over, and the archiver copies it to backupBackupA copy of data as it was at an earlier moment. Replicas copy a bad write within seconds, so recovering data from before it needs an older copy. A backup is proven only by a test restore.Read more: Replication: Undoing a bad write storage (more on standbys). The server that takes writes is the primary.
What runs inside one PostgreSQL instance Three app servers each open a connection, and the postmaster starts one backend process for each. The backends and six background processes share one area of memory with shared buffers, WAL buffers, and locks. The WAL writer writes the WAL file, the checkpointer and the background writer write the data files, autovacuum cleans tables, WAL senders stream the WAL to standbys, and the archiver copies it to backup storage. App server App server App server INSTANCE postmaster STARTS backend backend backend Shared memory shared buffers: 8 KB pages WAL buffers locks WAL writer checkpointer bgwriter autovacuum WAL sender archiver DISK WAL Data files Standbys Backups

One postmaster with its memory and data directory is an instance; PostgreSQL itself calls it a database cluster, even though it runs on one machine. An instance can hold several databases, but they share memory, processes, and one log of changes. A heavy database slows down its neighbors, and standbys and physical backups always copy the whole instance.

Say that every write goes to one PostgreSQL server, so plan its CPU, memory, and disk for the peak write load. Put a connection pool between the app servers and the database, and size memory so that the data most queries read fits in RAM.

A guest pays for a booking, and the app shows “Booked” as soon as COMMIT returns. If the server loses power a second later, the booking must still be there after the restart, although the shared buffers are gone.

PostgreSQL keeps this promise with the WAL. A backend that changes a page in shared buffers also appends a record of the change to the WAL. At COMMIT, it waits until the WAL up to its commit recordCommit recordThe entry in the write-ahead log that marks a transaction as committed. The transaction is committed once this entry is on disk. After a crash, the database keeps transactions that have one and removes the rest.Read more: Transactions: Undoing a half-done transaction is flushed to disk with fsync. The changed data pages stay in memory and reach the data files later. The WAL is written in order, and one flush can carry the commitsCommitThe end of a transaction that makes all of its changes permanent at once. Other transactions see the changes only after the commit. A committed change is final: undoing it takes a new transaction that does the opposite.Read more: Transactions: Changing several rows at once of many transactions that wait at the same moment. So a commit waits for a sequential write of the log, not for a write of every page it changed. After a crash, PostgreSQL replays the WAL and redoes the changes that had not reached the data files.

One WAL flush returns several commits Transactions T1, T2, and T3 each append a commit record to the WAL. One fsync writes all three records to the WAL file, and then all three COMMITs return. The changed data pages reach the data files later, at a checkpoint. T1 T2 T3 WAL FILE DATA FILES T1 COMMIT T2 COMMIT T3 COMMIT one fsync OK OK OK AT A CHECKPOINT pages 7, 12, 31

Replay starts from the last completed checkpointCheckpointA mark in the log from which recovery after a crash can start. The database writes changed pages to disk from time to time and records a checkpoint, so recovery does not replay the log from the beginning.Read more: Transactions: Undoing a half-done transaction. A checkpoint writes all pages changed before its start to the data files; once it completes, the WAL before its start is no longer needed for crash recovery and can be reused. A checkpoint starts every 5 minutes (checkpoint_timeout) or when the WAL grows toward its size target (max_wal_size, 1 GB by default), whichever comes first, and spreads its writes over most of the interval (PostgreSQL). Rare checkpoints write each busy page fewer times and create less WAL, but recovery after a crash replays more of it. When the logs show that most checkpoints are triggered by WAL volume rather than by time, raise max_wal_size. It is a target, not a limit: standbys and backups that still need old WAL keep it on disk longer (more on replication slots).

Which WAL a crash recovery replays The WAL as a row of segments, oldest on the left. Checkpoints started at 10:00 and at 10:05, and the 10:05 checkpoint completed. The server crashed at 10:08. Recovery replays the WAL from the start of the 10:05 checkpoint to the crash; the WAL before it is no longer needed and can be reused. CHECKPOINT 10:00 CHECKPOINT 10:05 CRASH 10:08 NOT NEEDED, REUSED REPLAYED

A page is 8 KB, but a disk writes in smaller blocks, often 4 KB. A crash in the middle of a page write can leave a torn page, half old and half new, and a WAL record cannot repair a page it cannot read. So the first change to a page after each checkpoint puts a copy of the whole page into the WAL. These copies can be most of the WAL: 72% of it in one pgbench run in Rogov’s book, so measure their share in your own workload (Rogov). Rarer checkpoints mean fewer copies, and WAL compression (wal_compression, LZ4 or Zstandard since PostgreSQL 15) makes them smaller at the cost of CPU.

A torn page and the full page copy in the WAL Top: page 7 is 8 KB, written as two 4 KB blocks. A crash after the first block leaves the page half new and half old. Bottom: after a checkpoint, the first change to page 7 puts the whole 8 KB page into the WAL, and the next change to it only a small record. The same happens for page 12. Page 7 on disk 8 KB 4 KB: new 4 KB: old CRASH MID-WRITE WAL CHECKPOINT page 7: full 8 KB page 7: 60 B page 12: full 8 KB page 7: 60 B

Some data can afford to lose its last second of commits. With asynchronous commit (synchronous_commit = off), COMMIT returns before the flush, and the WAL writer flushes within three times wal_writer_delay, 0.6 seconds by default. A crash loses the transactions acknowledged in that window, and the database stays consistent, as if they had rolled backRollbackThe end of a transaction that discards all of its changes. The app rolls back when a check fails, and after a crash the database rolls back every transaction that did not commit.Read more: Transactions: Changing several rows at once. In one pgbench run, a single client with short transactions went from 671 to 2,060 transactions per second (Rogov). Set it for one transaction or one session: view counters and activity logs can use it, bookings and payments cannot.

synchronous_commit on and off, with the same crash Left, synchronous_commit on: the client sends COMMIT, the server flushes the WAL, and only then answers OK; a crash after that keeps the booking. Right, off: the server answers OK at once and would flush within 0.6 seconds, but it crashes before the flush, and the acknowledged commit is lost, as if it had rolled back. synchronous_commit = on CLIENT SERVER COMMIT flush WAL OK CRASH booking kept synchronous_commit = off CLIENT SERVER COMMIT OK flush CRASH booking lost

synchronous_commit = off can lose the last fraction of a second of acknowledged commits and nothing more. fsync = off and full_page_writes = off let a crash leave pages broken, so the whole instance may need a restore from backup. Keep both on for any data you keep.

A flush is only as safe as the disk under it. A drive or controller with a write cache that loses its contents on power loss can report a write as done before it is on disk. Use storage that keeps flushed data through a power loss, and check the flush speed with pg_test_fsync.

Corruption can also come from a bad disk or bad memory. Data checksums store a checksum in every page and detect a damaged page when it is read, instead of returning wrong data. New instances get them by default since PostgreSQL 18 (PostgreSQL); an older instance needs pg_checksums, run while the server is stopped.

Keep the defaults that protect data: synchronous_commit, fsync, and full_page_writes on, and data checksums on. Turn off synchronous_commit only for transactions whose last second you can lose.

Two guests try to book the same home: one for 12–15 May, the other for 14–16 May. Two app servers check the calendar at the same moment, both see free dates, and both insert a booking. The check and the insert are separate steps, so a check in the app cannot stop this (more on rules across several rows).

PostgreSQL can hold the rule itself. A range type such as daterange stores a period as one value, and an exclusion constraint rejects a row that conflicts with an existing one under the operators you list. Here the rule is “the same home and overlapping dates”:

CREATE EXTENSION btree_gist;
CREATE TABLE bookings (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
home_id bigint NOT NULL REFERENCES homes (id),
stay daterange NOT NULL,
status text NOT NULL DEFAULT 'confirmed',
EXCLUDE USING gist (home_id WITH =, stay WITH &&)
);

The example assumes each stay has both days and is not empty; a CHECK (NOT isempty(stay) AND NOT lower_inf(stay) AND NOT upper_inf(stay)) makes that a rule too. The second insert fails with a conflict error, and the app tells that guest the dates are taken. A daterange built from the check-in and check-out days includes the first and excludes the last, so a check-out day stays free for the next check-in. To let cancelled bookings free their dates, add WHERE (status <> 'cancelled') to the constraint; status is NOT NULL, because a row with no status would skip the check. PostgreSQL enforces the constraint through a GiST index, which each covered insert updates. It cannot enforce the rule across separate databases: once bookings are split across several of them, the app needs its own way to keep the rule. How to build a whole booking flow with holds and expiry is a separate pattern.

The exclusion constraint on one home's calendar Nights of 11 to 17 May for home 7. A stored booking covers 12 to 15 May: the nights of the 12th, 13th, and 14th. A booking for 14 to 16 May overlaps it on the night of the 14th and is rejected. A booking for 15 to 17 May starts on the check-out day of the first one, does not overlap, and is stored. HOME 7, NIGHTS OF MAY 11 12 13 14 15 16 17 [12 May, 15 May) STORED [14 May, 16 May) REJECTED [15 May, 17 May) STORED

Simpler rules work the same way, and every writer obeys them: the app, a script, and a hand-run migration. NOT NULL, CHECK (price > 0), UNIQUE, and foreign keysForeign keyA column or group of columns whose values point to a unique key of a row, usually in another table. It links the two rows. The database can reject a value that points to no row and remove dependent rows on delete.Read more: Databases: Storing each fact once cover most of them. A unique index with a WHERE clause makes values unique among some rows only, such as one active listing per address. A foreign key does not create an index on the column that refers to the parent. Without one, deleting a host or joining homes to their host scans the whole table, so index the foreign key columns you joinJoinA query operation that combines rows of tables by a matching condition, usually on keys. It turns stored IDs back into the data they point to, such as an author's name for a post. Every join is work on each read.Read more: Databases: Storing each fact once or delete on.

Types are rules too. Pick them by what the value means:

Value Type Avoid
A moment, such as when a booking was paid timestamptz timestamp, which stores clock digits without a time zone and gets durations wrong across daylight saving changes
A day or a period of days date, daterange Two timestamps that the app must keep in order
Money numeric, with the currency in its own column float, which rounds, and money, which takes its currency from a server setting
Text with a length rule text with a CHECK varchar(n) with a guessed limit; char(n), which pads with spaces
A record ID bigint identity int, which runs out at about 2.1 billion

An identity column takes numbers from a sequence. A sequence hands out numbers to concurrent transactions without waiting, and a rollback does not return a number, so gaps are normal: do not use it for invoice numbers that must have no gaps. A sequence can also be reset by hand, so keep the primary keyPrimary keyThe column or columns that identify each record of a table. No two records share it. The database keeps an index on it to find records by it.Read more: Wikipedia: Primary key that makes the IDs unique. A random UUID (version 4) lets any server create an ID without asking the database, but its index lands every new key on a random page. In a test with 100 million rows, inserts with UUIDv4 keys took 388 s against 83 s with bigint, and the index was 40% larger (Angelakos). PostgreSQL 18 adds uuidv7(), which starts with a timestamp, so new keys usually land near the end of the index, much like numbers do.

Where new keys land in a B-tree index The same six leaf pages of a primary key index in both panels. Left, bigint or uuidv7 keys: five new keys all go to the last page. Right, random UUIDv4 keys: the five new keys land on five different pages. With 100 million rows, the inserts took 83 s with bigint and 388 s with UUIDv4. bigint or uuidv7 new keys at the end 1001 1002 1003 1004 1005 p1 p2 p3 p4 p5 p6 83 s UUIDv4 new keys at random 07de… 3f2a… 5c10… b81c… e4a9… p1 p2 p3 p4 p5 p6 388 s

Access rules can live in the database as well: roles limit what each app may touch, and row-level security hides other tenants’ rows even when a query forgets to filter them.

Put the rules that must hold for all writers into the table definitions wherever PostgreSQL can express them: types, NOT NULL, CHECK, unique and foreign keys, and exclusion constraints for overlaps. Hold the remaining rules across rows with transactions and locks, and keep in the app only the rules that need data from outside the database.

A house has a pool and parking, an apartment has a floor and an elevator, and a boat has berths. Hosts fill in dozens of such fields, and new ones appear every month. A column for each would leave most of them empty, and every new field would need a schemaSchemaThe structure that records follow: their fields and the types of the fields. A relational table enforces its schema on every write. Without that check, the code that reads the data still relies on one.Read more: Databases: Records with different fields change (more on records with different fields).

JSONB stores a JSONJavaScript Object NotationA text format for nested data: objects with named fields, lists, strings, numbers, and booleans. Document stores keep records as JSON or a binary form of it, and relational databases have JSON columns.Read more: Wikipedia: JSON document in a column in a parsed binary form, so PostgreSQL can query and index the fields inside it. The plain json type keeps the original text and parses it on every read; pick it only when the exact text matters. Most designs mix typed columns and JSONB: columns for what queries filter, join, sort, and constrain (price, number of guests, city), and one JSONB column for the long tail of attributes.

SELECT id, title
FROM homes
WHERE city = 'Lisbon'
AND attrs @> '{"pool": true}';
-- 1042 | Villa with a pool in Belém
-- 2210 | Townhouse with a garden pool

Two kinds of index fit JSONB:

  • A GIN index on the whole column indexes every key and value at any depth. It serves containment (@>), key tests (?), and JSONPath matches (@?, @@) on the whole document. It is large and slows down writes. Its jsonb_path_ops variant is smaller and serves containment and JSONPath, but not key tests.
  • A B-treeB-treeA storage structure that keeps sorted keys in a tree of fixed-size pages and changes them in place. A lookup follows one page per level, and the upper levels stay in memory. Leaves in key order serve ranges. It is the default index of most relational databases.Read more: Storage engines: Updating in place index on one expression, such as (attrs->>'floor'), serves that one key, like a normal column. On 2.4 million documents, a GIN index over whole documents took 1,680 MB, while a B-tree on one extracted key took 61 MB (Angelakos).
A GIN index and an expression index on the same documents Three homes with JSONB attributes. Left, a GIN index on the whole column has an entry for every key and every value: elevator, floor, parking, pool, 3, street, and true, each with the homes that contain it. Right, a B-tree index on attrs->>'floor' has one entry per home: 3 for home 3317 and NULL for the others. On 2.4 million documents, such a GIN index took 1,680 MB and the B-tree 61 MB. HOMES 1042 {"pool": true, "parking": "street"} 2210 {"pool": true} 3317 {"floor": 3, "elevator": true} GIN on attrs B-tree on (attrs->>'floor') elevator 3317 floor 3317 parking 1042 pool 1042 2210 3 3317 "street" 1042 true 1042 2210 3317 3 3317 NULL 1042 2210 1,680 MB on 2.4 M documents 61 MB on 2.4 M documents

JSONB has costs that columns do not. The planner keeps no statistics about keys inside a document, so it guesses how many rows a JSONB filter returns. For a key that matters, an expression index or CREATE STATISTICS on the expression (PostgreSQL 14) gives it statistics. Changing one key writes a new copy of the whole row, and a large document is also compressed and stored apart from the row. PostgreSQL checks that a document is valid JSON, but types and foreign keys only for columns, so a typo in a key or a string where a number belongs reaches the data unless a CHECK catches it.

When a key starts to matter, a generated column turns it into a real column. PostgreSQL computes its value from other columns of the same row, and with STORED it does so on every insert and update and keeps the result like any other column. The app keeps writing the document, and the column gets a type, constraints, indexes, and statistics. A floor that is not a number now fails the write, so the column also checks the document:

ALTER TABLE homes
ADD COLUMN floor int
GENERATED ALWAYS AS ((attrs->>'floor')::int) STORED;

Adding a stored generated column rewrites the table under a lock (more on schema changes under load).

Keep the core fields as typed columns and the rare, varied ones in JSONB. When a key appears in most queries or needs a rule, move it into a column, for example a generated column that fills it from the document.

As the service grows, hosts publish thousands of homes a day and edit them often. How fast that goes depends on how PostgreSQL lays rows out on disk.

A table is a heap: a file of 8 KB pages with rows in no particular order. A new row goes to any page with enough free space. Each row carries a header of at least 23 bytes that records which transactions see it. In a table of a few narrow columns, headers and padding take much of the space. Column order matters a little: values are aligned in memory, and placing wide fixed-size columns first can save padding bytes in every row.

The address of a row is its TID: the page number and the slot on that page. A TID changes when the row is updated, so it is never an ID for the app. Every B-tree index, the primary key included, maps its keys to TIDs; a BRIN index is the exception, it only summarizes ranges of pages. MySQL’s InnoDB works the other way: the table itself is a tree ordered by the primary key, and other indexes store the primary key (more on indexes on other columns). In PostgreSQL, a lookup by any index takes one step from the index to the row. But PostgreSQL writes every update as a new version of the row, usually in a new place, and then every applicable B-tree index needs a new entry for it, unless the update is a HOT update.

PostgreSQL heap with TIDs and the InnoDB primary key tree The same three homes in both panels. Left, PostgreSQL: the rows lie in heap pages in no order; both the index on city and the primary key index store TIDs, the page and slot of each row, so any index leads to the row in one step. Right, MySQL InnoDB: the table is a tree ordered by the primary key, and the index on city stores primary keys, so a lookup by city goes through the primary key tree. PostgreSQL MySQL InnoDB INDEX ON CITY Lisbon (0,2) Lisbon (1,1) Porto (0,1) PRIMARY KEY 1042 (0,2) 2210 (0,1) 3317 (1,1) INDEX ON CITY Lisbon 1042 Lisbon 3317 Porto 2210 HEAP PAGE 0 1 · 2210 Porto 2 · 1042 Lisbon PAGE 1 1 · 3317 Lisbon TID TID TABLE = PRIMARY KEY TREE 1042 · Lisbon 2210 · Porto 3317 · Lisbon PRIMARY KEY

Rows are not kept in the order of any key. Reading the bookings of one home by an index can visit almost one page per booking, scattered over the table. In one test, fetching 9,999 rows stored in order touched 85 pages, while the same rows spread at random touched about 8,000 (Schönig). CLUSTER sorts a table by an index once, under a lock that blocks all access, and does not keep the order for new rows.

Rows of one home stored in order and spread over the table The same eight heap pages and six bookings of home 1042 in both panels. Left, the bookings sit together, and the index lookup reads one page. Right, they are spread, and it reads six pages. In a test, 9,999 rows stored in order took 85 pages, and spread at random about 8,000. Stored in order home_id = 1042 9,999 rows: 85 pages Spread over the table home_id = 1042 9,999 rows: about 8,000 pages

A row must fit in a page, so long values go elsewhere. When a row is wider than about 2 KB, TOAST compresses its long values and, if that is not enough, moves them in chunks to a separate table. A query that does not ask for a moved value never reads it, so select only the columns you need instead of SELECT *. PostgreSQL 14 added LZ4 compression as an option next to the default; compare the two on your data. One value can hold up to 1 GB, and one table up to 32 TB with the default page size; a partitioned table can hold more (PostgreSQL).

How TOAST stores a long value A row of home 1042 with id, title, price, and an 18 KB description. The description is compressed and moved in chunks of about 2 KB to the TOAST table. The stored row keeps only a pointer in its place, so the row stays small. Row as written Row as stored TOAST table 1042 1042 Sea view villa Sea view villa 120 120 description: 18 KB pointer COMPRESS, MOVE chunk 0 chunk 1 chunk 2 chunk 3

Do not index long text, such as a home description, with a B-tree. An entry must fit in about a third of a page, so a long value fails to insert. Search such text with a full-text index.

Guests search for homes in a city for four people and sort them by price. Hosts open their bookings for the next month. Support looks up a booking by its code. With millions of rows, each of these queries needs an index, and the right one depends on how the query compares values.

PostgreSQL picks an index by the operator in the query, not by the column. It has several kinds of index, each for its own operators:

Index Answers In the rental service
B-tree, the default =, <, >, BETWEEN, ORDER BY, a text prefix (with text_pattern_ops under most locales) Price ranges, dates, IDs, sorting
Hash = only Rarely worth it over a B-tree
GIN “contains”: a key in a document, an element in an array, a word in a text JSONB attributes, search by words
GiST “overlaps” and “near”: ranges, shapes, distances Booking periods, homes near a point
SP-GiST Points and prefixes in a space split into parts Phone or IPInternet Protocol addressThe number that identifies a device on a network. IP, the Internet Protocol, delivers packets to these addresses with no guarantees; TCP and UDP run on top of it. DNS maps names to IP addresses.Read more: Networking: Finding the server prefixes, points on a map
BRIN A summary of each range of pages, 128 pages (1 MB) by default A huge log table written in time order

A B-tree serves most queries. A composite indexComposite indexAn index on several columns, sorted by the first column, then by the next. Put the columns compared with = first and the sort or range column last. It usually cannot help a query that uses only a later column.Read more: Storage engines: Searching by other columns over several columns is sorted by the first column, then by the second inside it, so the order of its columns decides which queries it reads efficiently. For an index on (city, price):

  • WHERE city = 'Lisbon' uses it: the city is the first column.
  • WHERE city = 'Lisbon' AND price < 100 uses both columns. The order of the conditions in WHERE does not matter, only the order of the columns in the index.
  • WHERE city = 'Lisbon' ORDER BY price reads the homes of the city already sorted by price, with no sort step.
  • WHERE price < 100 gets little help. The prices are spread across all cities, so PostgreSQL would have to read most of the index, which rarely beats reading the table. PostgreSQL 18 can skip from one city to the next inside the index, which pays off when the first column has few distinct values.
  • WHERE city > 'L' AND price < 100 uses the range on the city to find where to start, then checks the price of the entries in that range inside the index.
What an index on (city, price) reads for two queries The same eight entries of an index on city and price, in index order, in both panels. Left, city = Lisbon: the four Lisbon entries lie together, already sorted by price, and the scan reads only them. Right, price < 100: the matching entries, Faro 80, Lisbon 60, Lisbon 95, and Porto 70, are spread across the cities, so the scan reads the whole index. WHERE city = 'Lisbon' Faro 80 Faro 140 Lisbon 60 Lisbon 95 Lisbon 120 Lisbon 210 Porto 70 Porto 150 READS 4 WHERE price < 100 Faro 80 Faro 140 Lisbon 60 Lisbon 95 Lisbon 120 Lisbon 210 Porto 70 Porto 150 READS ALL 8

One composite index can make another redundant: (city, price) also serves the queries of a non-unique index on city alone. The shorter index is smaller, though, so check its use and the plans before you drop it. To serve queries by city alone and by price alone, you need two indexes: (city, price) and (price). Two single-column indexes also work for a query on both columns, because PostgreSQL can combine their matches with a bitmap scan; a composite index is usually faster for a frequent combination.

Three variants make an index smaller or fit a query better:

  • A partial index (WHERE status = 'active') covers only the rows the queries look for, so it is smaller and cheaper to update. The planner uses it only when it can prove from the query’s conditions that the rows match, so write the same condition in the query, not a parameter in its place.
  • An expression index (lower(email)) stores the result of a function. A query uses it only when it has the same expression. A function over an indexed column, such as created_at::date = '2026-05-12', cannot use a plain index, and this cast depends on the session’s time zone, so it cannot be indexed as written either. A range such as created_at >= '2026-05-12' AND created_at < '2026-05-13' can use the index; pick the time zone of the day boundaries on purpose.
  • A covering indexCovering indexAn index that holds every column a query needs. The database can answer the query from the index without reading the records. The price is a bigger index.Read more: Storage engines: Searching by other columns adds columns that the query only returns (INCLUDE (title)), so an index-only scan can answer without reading the table. It still checks a visibility map to know which pages hold only rows that every transaction sees; for other pages it reads the table after all. Vacuuming keeps that map current.
An index-only scan and the visibility map A covering index on city and price that includes the title has four Lisbon entries, on heap pages 0, 0, 1, and 2. The visibility map says pages 0 and 1 hold only rows that every transaction sees, so the scan answers from the index alone. Page 2 is not marked, so the scan reads heap page 2. (CITY, PRICE) INCLUDE (TITLE) Lisbon 60 Loft in Alfama p0 Lisbon 95 Flat in Graça p0 Lisbon 120 Studio by Rossio p1 Lisbon 210 Villa in Belém p2 CHECK VISIBILITY MAP p0 all visible p1 all visible p2 not marked HEAP page 0 SKIP page 1 SKIP page 2 READ

An index pays off when a frequent query needs a small share of the rows. When a query matches a large share, reading the table pages in order is usually cheaper than jumping between the index and the table, and the planner prefers that, unless the index also gives the order for a LIMIT or answers the query alone. Look at how many rows the queried value matches, not at how many distinct values the column has: a column with few values can still have a rare value worth an index. A status column has three values, and 99% of homes are active. An index on it does not help a query that reads the active homes, but the moderation queue asks for the few homes pending review, and for that value an index finds them at once. The planner knows the difference: its statistics keep the most common values of each column with their shares, so it reads the index for pending and the table for active. A partial index fits such a case best: ON homes (created_at) WHERE status = 'pending' holds only the rare rows, stays tiny, and changes only when a row enters, changes in, or leaves that set.

Every index has a price. Each insert updates every index of the table (a partial index only for the rows it covers), and an update that is not HOT adds an entry to every applicable index that points to rows, even when the indexed values did not change (more on what an update writes). Indexes also take disk space and memory, and add work to vacuuming, backups, and the planner. Look for indexes that queries never use in pg_stat_user_indexes (idx_scan = 0) over a long enough period. Before you drop one, check that it does not enforce uniqueness and that queries on the standbys do not use it, because each server counts its own scans.

Building an index on a live table blocks writes to it until the build ends. CREATE INDEX CONCURRENTLY lets writes go on, but it scans the table twice, takes longer, cannot run inside a transaction, and leaves an invalid index behind if it fails, which you drop and build again (more on schema changes under load).

Given an index on (a, b), say which queries use it well: a filter on a, on a and b in any order of conditions, and on a sorted by b. A filter on b alone has to read most of the index, unless a has few values and the database can skip through them. Put first the column that most queries filter by with =.

Start with B-tree indexes on the columns that frequent queries filter, join, and sort by, equality columns first; index foreign keys you join on. Reach for GIN, GiST, or BRIN when the operator needs it, and confirm each index with EXPLAIN before you keep it.

The search page slows down as the number of listings grows. Before you add an index or a bigger server, find which query costs the most and why.

Start with the totals. The pg_stat_statements extension groups queries that differ only in their parameters and counts the calls, the total time, and the pages read of each group. Sort by total time, not by the slowest single run: a 2 ms query called 5,000 times per second costs more than a 3-second report run once an hour. log_min_duration_statement writes slow statements to the log, and the auto_explain extension also writes their plans, so you can see the plan of a run that is hard to reproduce.

The query planner picks the plan with the lowest estimated cost. It estimates how many rows each step returns from statistics that ANALYZE collects from a sample of each table: the most common values, a histogram of the rest, and the number of distinct values. From those estimates it picks how to read each table: a full scan, an index scan, an index-only scan, or a bitmap scan. A bitmap scan collects the matches from indexes first and then reads their pages in page order. The planner also picks how to join the tables:

  • A nested loop runs the inner side once for each row of the outer side. It works well when the outer side is small and the inner side is an index lookup.
  • A hash join builds a hash table from one input in memory and probes it with the other. It suits large inputs joined by equality.
  • A merge join walks two inputs sorted by the join key. It suits inputs that already come sorted, for example from indexes.
Three ways to join homes and bookings The same homes 1, 2, 3 and bookings 11, 12, 13 for homes 1, 3, and 3 in each panel. Nested loop: for each home, a lookup in the bookings index. Hash join: a hash table built from the homes, probed with each booking. Merge join: both inputs sorted by home, walked together. Nested loop OUTER INNER home 1 home 2 home 3 bookings index Hash join PROBE HASH OF HOMES 1 2 3 b11 · 1 b12 · 3 b13 · 3 Merge join HOMES BOOKINGS home 1 home 2 home 3 b11 · 1 b12 · 3 b13 · 3 BOTH SORTED BY HOME

EXPLAIN shows the chosen plan, and EXPLAIN (ANALYZE, BUFFERS) runs the query and adds the real row counts, times, and pages read for each step. Costs are planner units, not milliseconds. A step that runs many times, such as the inner side of a nested loop, shows loops: its rows and time are per run, so multiply them by loops. Look for the first step where the estimated rows differ from the real ones by orders of magnitude: a wrong estimate there can make the steps above it bad choices. BUFFERS splits pages found in shared buffers from pages read from files, which may still come from the OS cache. A query can also be slow without a bad plan: it may wait for a lock, which pg_stat_activity shows as its wait event, with the blocking sessions from pg_blocking_pids(). ANALYZE in EXPLAIN really runs the statement, so wrap an UPDATE or DELETE in a transaction you roll back.

Estimated and actual rows in a plan A nested loop over two index scans. The planner estimated 5 homes in Lisbon, but the scan found 4,800: this is the first step where the estimate is wrong by orders of magnitude. The inner index scan on bookings returned 10 rows per run and ran 4,800 times, 48,000 rows in all, so the nested loop, estimated at 50 rows, returned 48,000. PLAN STEP ESTIMATED ACTUAL LOOPS Nested Loop 50 48,000 1 Index Scan on homes city = 'Lisbon' 5 4,800 1 Index Scan on bookings home_id = homes.id 10 10 4,800

The usual causes and fixes:

  • Stale statistics after a bulk load or a large delete: run ANALYZE on the table.
  • Columns that depend on each other, such as city and country: the planner multiplies their selectivities as if they were independent and underestimates rows. CREATE STATISTICS teaches it the link.
  • A function over an indexed column, as in the expression index above: rewrite the condition, or index the expression when it does not depend on settings such as the time zone. On an airline sample database, replacing a cast to date with a time range cut a query from 1.5 s to 0.5 s (Dombrovskaya et al.).
  • One query, different values: a city with ten homes and a city with a million need different plans. After five runs, a prepared statement may switch to one generic plan that fits neither; plan_cache_mode changes that.
  • A sort that does not fit work_mem (4 MB by default) spills to temporary files; a hash may use twice that (hash_mem_multiplier). The limit applies to each step of each query and each parallel worker, so the total grows with the number of steps and sessions. Raise it for the session of the report that needs it, not for the whole server (PostgreSQL).
How work_mem adds up One query with a sort and a hash step, run by a leader and two parallel workers. Each sort may take work_mem, 4 MB by default, and each hash twice that, 8 MB: 36 MB for the query. One hundred such queries at once may take 3.6 GB. SORT HASH leader 4 MB 8 MB worker 1 4 MB 8 MB worker 2 4 MB 8 MB 36 MB PER QUERY 3.6 GB 100 such queries
  • Deep pages with OFFSET: the server reads and throws away every skipped row. On 10 million rows, OFFSET 5000000 LIMIT 50 read 117,289 pages, while a condition on the last seen key (WHERE id > $1 ORDER BY id LIMIT 50) read 2 (Atkinson).
A deep page with OFFSET and with a condition on the last key The same index on id over 10 million rows in both panels. Left, OFFSET 5000000 LIMIT 50 reads the first 5 million entries, throws them away, and returns the next 50: 117,289 pages read. Right, WHERE id > the last seen id goes straight to that place in the index and reads 50 entries: 2 pages. OFFSET 5000000 LIMIT 50 ID 1 ID 10,000,000 READ, THROWN AWAY RETURNED: 50 117,289 pages read WHERE id > $1 LIMIT 50 ID 1 ID 10,000,000 SEEK RETURNED: 50 2 pages read
  • Many small queries instead of one: an ORM that loads a list and then each item separately (the N+1 pattern) multiplies network round tripsRound tripThe time for a message to reach the other side and for the answer to come back. Also called RTT (round-trip time). Every handshake costs at least one round trip, so distance between client and server adds up fast.Read more: MDN: Round trip time. In one legacy app, a form issued about 16,000 queries and took 5–7 minutes; one query that returned the same data took under 200 ms (Dombrovskaya et al.).
  • A join of many tables generated by an ORM: at OpenAI, bursts of one 12-table join caused severe incidents, and the fix was to split it and move part of the work into the app (OpenAI, 2026).

Core PostgreSQL has no hints that force a plan. Fix the statistics, the indexes, or the query; the pg_hint_plan extension adds hints, but a forced plan stops adapting when the data changes.

Rank queries by total time in pg_stat_statements. For the top ones, check first whether they wait for locks, then read the plan with EXPLAIN (ANALYZE, BUFFERS) and fix the earliest real cause: a wrong row estimate, a missing index, a spill, or too much data read. Count the queries per page as well as their speed.

The service now runs 40 app servers with 20 connections each, plus background workers: more than 800 connections, while max_connections is 100 by default. Raising the limit to 1,000 makes the server accept them, but it does not make it faster.

Memory is a smaller problem than it seems: in 2020 tests with huge pages, the base cost of a connection was under 2 MB, though caches and query memory add to it (Freund). The same tests found a bigger cost: every query takes a snapshotSnapshotThe state of data at one moment. An in-memory store saves one to disk for recovery and replays the log written after it. A transaction can read from one, so data does not change under it.Read more: Storage engines: Back to memory of the transactions running at that moment, and building it took longer with every open connection, so one active connection ran at less than half its speed next to 5,000 idle ones. PostgreSQL 14 made snapshots much cheaper. The limit that remains is active work: past some number of active connections, close to the number of cores for queries that mostly use the CPU, throughput stops growing while latency keeps rising. In a pgbench test, 32 clients got 572 transactions per second at 56 ms each, and 2,000 clients got 352 at 5.5 seconds each (Angelakos).

Opening a connection is slow too: a new process, authentication, and often TLSTransport Layer SecurityThe protocol that encrypts a connection and proves the server is who it claims to be. During the TLS handshake, the client checks the server certificate and both sides agree on keys. After that, all data is encrypted. HTTPS is HTTP over TLS.Read more: Networking: Securing the connection. OpenAI measured 50 ms to open a connection directly and 5 ms through a pool (OpenAI, 2026).

A connection pooler, such as PgBouncer, sits between the apps and the database. Apps open as many connections to it as they like, and it passes their work to a small set of server connections. In transaction mode, the usual choice, a client gets a server connection only for the length of one transaction, so a few dozen server connections can serve thousands of clients. The price is that nothing may outlive a transaction on a connection: SET for the session, session advisory locks, LISTEN, temporary tables kept across transactions, SQL PREPARE, and cursors kept open break. SET LOCAL and temporary tables with ON COMMIT DROP work, because they end with the transaction. Prepared statements of the client protocol work since PgBouncer 1.21 with max_prepared_statements set (PgBouncer). Session mode keeps all features but holds a server connection for as long as the client stays connected, so it cannot share connections among clients that stay connected at the same time.

A session setting behind a pooler in transaction mode Two clients share two server connections, one transaction at a time. Client A runs SET statement_timeout = 1s on server connection 1, and the setting stays on that connection. B's next transaction gets connection 1 and runs with A's timeout, while A's next transaction gets connection 2 and runs without it. CLIENT A SERVER CONNECTION 1 SERVER CONNECTION 2 CLIENT B SET statement_timeout = '1s' TRANSACTION timeout = 1s STAYS TRANSACTION B: timeout 1s TRANSACTION A: no timeout

Size the pools from the server side. App pools add up: 40 servers × 20 connections is 800, before any workers. Start with a few server connections per CPU core, raise the number while throughput still grows, let the pooler queue the rest, and keep the pooler’s total below max_connections, with room for admins. At Notion, about 100 PgBouncer instances could each open 6 connections to every database server, 600 connections per server. Before tripling the number of servers, Notion split its poolers into groups, each serving fewer servers (Notion, 2023). At Railway in 2025, PgBouncer allowed more connections than PostgreSQL did, so a blocked table used up every slot, and engineers could not log in to cancel the blocking command (Railway, 2025).

Many client connections, few server connections 40 app servers with 20 connections each open 800 client connections to PgBouncer. PgBouncer passes their transactions to 20 server connections to PostgreSQL. App server 1 App server 2 App server 3 App server 40 800 CLIENT CONNECTIONS PgBouncer transaction mode 20 SERVER CONNECTIONS PostgreSQL 20 backends

Timeouts keep one bad client from holding connections forever: statement_timeout for long queries, idle_in_transaction_session_timeout for a transaction that an app forgot to finish, and lock_timeout for waits on locks. All are off by default; set them for each app’s database role. Close idle connections in the pooler rather than with idle_session_timeout (PostgreSQL 14) on the server, since a pooler may not expect its connections to disappear.

Draw the app servers, then PgBouncer in transaction mode, then PostgreSQL. Say that a few dozen server connections serve thousands of clients, and that session features such as SET or session advisory locks do not work through it.

Every time a guest opens a listing, the app runs UPDATE homes SET views = views + 1, and hosts change prices every day. The homes table has eight indexes, and none of them is on views. Yet each of these updates can write to all eight.

PostgreSQL implements MVCCMulti-version concurrency controlKeeping several versions of a row so that each transaction reads the one it should see. Plain reads do not block writers, and writers do not block plain reads. Old versions stay until no transaction needs them, so long transactions make data grow.Read more: Transactions: Reading a consistent picture inside the table itself. It never changes a row in place: an UPDATE marks the current version as ended by its transaction and writes a full new version of the row, with all columns, into the heap. A DELETE only marks the version as ended. Each version records the transaction that created it and the one that ended it, and a snapshot uses these marks to decide which version it sees. A rollback is cheap, because it only marks the transaction as aborted; its versions stay in the table until they are cleaned up.

The new version gets a new TID, and B-tree indexes point to TIDs, so each of them needs a new entry, even an index whose columns did not change. Uber described this in 2016: in a table with a dozen indexes, an update of one indexed field wrote to all twelve, all of it went into the WAL, and the WAL stream to standbys in another data center grew with it. That was one of the reasons Uber moved to MySQL, whose secondary indexesSecondary indexAn additional index that finds records by columns other than the primary key. Values need not be unique, so each entry also identifies its record: by its place in the table or by its primary key. Every write that changes the indexed columns must update it.Read more: Storage engines: Searching by other columns point to the primary key (Uber, 2016). In 2026, OpenAI still named this write amplificationAmplificationHow much more the disk reads, writes, or stores than the data the app asked for. Read, write, and space amplification measure different costs. Compare storage engines on the same workload, since the numbers depend on it.Read more: Storage engines: Choosing an engine as its main limit for heavy writes on PostgreSQL (OpenAI, 2026).

A HOT update (heap-only tuple) avoids the index writes. When the new version fits on the same page and no column used by a B-tree or another index that points to rows changes, PostgreSQL chains it to the old one inside the page. Since PostgreSQL 16, a change to a column with only a BRIN index on it does not prevent this; the BRIN summary may still need an update (PostgreSQL). The indexes keep pointing to the first version and follow the chain, and later reads clean up old versions on the page without waiting for vacuuming. Two things keep updates HOT:

  • Leave room on each page. A table’s fillfactor (100% by default) sets how full inserts may fill a page; fillfactor = 80 keeps a fifth of each page free for new versions, at the price of a larger table.
  • Do not index columns that change often. One index on views would turn every view into nine index writes again.
An update that is not HOT and a HOT update The same update of the views counter of home 1042, in Lisbon, price 120, host 7, in a table with eight indexes, none of them on views. Left, not HOT: the new version goes to page 9 at TID (9,1), and every index gets a new entry pointing to it, although no indexed value changed. Right, HOT: the new version fits on page 4 at (4,2) and is chained to the old one; the indexes keep pointing to (4,1) and are not written. Not HOT INDEX ON CITY Lisbon → (4,1) Lisbon → (9,1) INDEX ON PRICE 120 → (4,1) 120 → (9,1) INDEX ON HOST_ID 7 → (4,1) 7 → (9,1) + 5 MORE INDEXES PAGE 4 (4,1) v1 · 41 PAGE 9 (9,1) v2 · 42 8 new index entries HOT INDEX ON CITY Lisbon → (4,1) INDEX ON PRICE 120 → (4,1) INDEX ON HOST_ID 7 → (4,1) + 5 MORE INDEXES PAGE 4 (4,1) v1 · 41 PAGE 9 (4,2) v2 · 42 no index writes

Check the result in pg_stat_user_tables: n_tup_hot_upd against n_tup_upd shows the share of HOT updates, and since PostgreSQL 16, n_tup_newpage_upd counts the updates that did not fit on their page.

The counter also makes writes wait for each other. Each page view of a popular home updates the same row and waits for the row lock of the previous update (more on contention on one row). A new version of a wide row also copies all its columns except long values stored apart. Move such hot, small fields into a narrow table of their own, or count views elsewhere and write the totals in batches (more on counters inside PostgreSQL).

The old versions stay behind until something removes them.

Keep frequently updated columns out of indexes, leave free space on pages of tables with many updates (fillfactor 80–90), and watch the share of HOT updates. Move counters and timestamps that change on every request out of wide rows.

After a month, the homes table takes three times the space of its live rows, and queries read more pages for the same results. The old versions are still there.

VACUUM removes them. It finds versions that no running transaction can still see, deletes the index entries that point to them, and marks their space free for new rows. It runs next to normal reads and writes. But it rarely returns space to the operating system: it can cut off only empty pages at the end of the file, and for that moment it needs a lock that blocks the table. A table keeps the size it once grew to, with free space inside. This extra space, bloat, is normal in moderate amounts; it becomes a problem when it keeps growing.

Autovacuum runs vacuuming for you. About once a minute it looks at each database, and up to three workers vacuum the tables that need it. A table qualifies once its dead rows exceed 50 plus 20% of its rows; PostgreSQL 18 also caps the threshold at 100 million rows. For a table of 100 million rows, that still means 20 million dead rows before cleanup starts, so lower the share for large and busy tables, for example to 1% with autovacuum_vacuum_scale_factor = 0.01 on that table. Autovacuum is also throttled: after a set amount of work it sleeps for 2 ms, and that budget is shared by all workers, so more workers split the same budget. On fast disks, raise the budget (autovacuum_vacuum_cost_limit, which takes the default of 200 from vacuum_cost_limit) toward 2,000 and watch the disk load (Atkinson; PostgreSQL). Since PostgreSQL 13, tables that only get inserts are vacuumed as well; this keeps their visibility map current for index-only scans.

Vacuuming can remove only versions that the oldest snapshot still in use cannot see, in any table of the database. So one old snapshot or one old transaction that has written something keeps the newer dead rows everywhere: a report that runs for hours, a transaction the app opened and never closed (idle in transaction), a forgotten prepared transaction, a standby that asks the primary to keep versions for its queries, or a replication slot whose stopped consumer still holds an old horizon (xmin in pg_replication_slots). In a test of a job queue table, one long transaction let the queue grow to 60,000 waiting jobs within an hour, because each worker searched through thousands of dead job rows to find a live one (Brandur, 2015). Find such sessions by xact_start, backend_xid, and backend_xmin in pg_stat_activity, and cap them with idle_in_transaction_session_timeout and, since PostgreSQL 17, transaction_timeout.

A long transaction holds back vacuuming A report starts at 10:00 and runs past 11:00 with one snapshot. Old versions of home 7 ended at 09:55, 10:10, and 10:40. Vacuuming at 10:50 removes the version that ended before 10:00 and keeps the two that ended after it, because the report's snapshot may still need them. 09:50 10:00 10:10 10:20 10:30 10:40 10:50 11:00 Report one snapshot v1 ended 09:55 REMOVED v2 ended 10:10 KEPT v3 ended 10:40 KEPT HORIZON VACUUM

When a table is already bloated, a normal VACUUM will not shrink it. VACUUM FULL rewrites the table into a new file, but it holds an exclusive lock for the whole rewrite and needs free disk space for the new copy. The pg_repack and pg_squeeze extensions rebuild a table while it stays in use and take a strong lock only for a short moment at the end. Indexes bloat too: after many changes, B-tree pages can stay half empty. New keys that belong on such a page reuse its space, and fully empty pages are recycled, but PostgreSQL does not merge half-empty neighbors. Rebuild a bloated index with REINDEX CONCURRENTLY (PostgreSQL 12).

VACUUM and VACUUM FULL on the same table The same table of six pages with live and dead rows in both panels. Left, VACUUM: the dead rows become free space inside the same six pages, and the file keeps its size. Right, VACUUM FULL: the live rows are copied into a new file of three pages, under a lock that blocks all access. VACUUM BEFORE AFTER 6 PAGES, FREE SPACE INSIDE VACUUM FULL BEFORE AFTER 3 PAGES, NEW FILE

Keep autovacuum on and tune it per table for large, busy tables. Alert on transactions older than a few minutes, set idle_in_transaction_session_timeout, and run long reports on a standby built for them rather than on the primary.

Vacuuming has one more job, and this one cannot be put off. Each transaction that writes gets a transaction ID (XID), and every row versionRow versionOne state of a row, kept so that each transaction can read the state it should see. Versions are kept as separate copies of the row or rebuilt from an undo log. Readers pick the version they should see, and old versions are cleaned up later.Read more: Transactions: Undoing a half-done transaction stores the XIDs that created and ended it. XIDs are 32-bit numbers that wrap around, so PostgreSQL compares them on a circle: about 2 billion IDs behind the current one count as the past, and the rest as the future. Ordinary read-only transactions do not take XIDs. A row whose XID falls more than 2 billion behind would seem to come from the future and disappear from every query. At 1,000 writing transactions per second, 2 billion IDs last about 23 days.

The same row as the current XID moves on XIDs are compared on a circle: about 2 billion IDs behind the current one count as the past, and about 2 billion ahead as the future. A row written by XID 100 million is shown twice. Left, the current XID is 1.5 billion: the row is 1.4 billion behind, in the past, and visible. Right, the current XID has moved on to 2.3 billion: the row is 2.2 billion behind, past the half of the circle, so it looks like the future and disappears, unless vacuuming froze it before. CURRENT XID 1.5 B PAST FUTURE ROW, XID 100 M row visible CURRENT XID 2.3 B PAST FUTURE ROW, XID 100 M row hidden

To prevent this, vacuuming freezes old rows: it marks them as visible to every transaction, so their XID no longer matters and the number can be used again. Freezing runs as part of normal vacuuming. When the oldest unfrozen XID of a table falls 200 million behind (autovacuum_freeze_max_age), PostgreSQL starts an anti-wraparound vacuum on it, even if autovacuum is turned off. Unlike a normal autovacuum, it does not give way to other sessions that need a lock on the table, and on a large table it can run for hours and load the disks. If freezing still falls behind, PostgreSQL warns when 40 million IDs are left before the limit and refuses to start new writing transactions when 3 million are left. Reads still work, but the database is down for writes until vacuuming freezes the oldest rows, which first needs whatever holds them back, such as an old transaction, to end (PostgreSQL).

What happens as the oldest unfrozen XID ages The age of the oldest unfrozen XID in a database, not to scale. Up to 200 million, normal vacuuming freezes rows. From 200 million, an anti-wraparound vacuum runs and does not yield. With 40 million IDs left before the limit of about 2.1 billion, PostgreSQL writes warnings. With 3 million left, it refuses new writing transactions; reads still work. AGE OF THE OLDEST UNFROZEN XID, NOT TO SCALE normal vacuuming freezes rows anti-wraparound vacuum runs and does not yield warnings in the log no new writes reads still work 0 200 M LIMIT − 40 M LIMIT − 3 M 2.1 B

That has happened to real systems:

  • In 2015, Sentry stopped taking writes for most of a US working day. Vacuuming its largest table would have taken far longer, and the team truncated that table, whose data was not critical, to get writes back (Sentry, 2015).
  • In 2019, Mailchimp’s Mandrill sent only part of its email for more than a day. Its shardedShardOne part of a dataset that is split across nodes; sharding is the split itself. Each row lives in exactly one shard, and each shard has its own leader and followers, so data and writes can grow past one machine.Read more: Sharding: Splitting a table across nodes databases took uneven load, and autovacuum on the busiest shard likely fell behind or failed; the team could not tell which. The team had noticed three months earlier that XIDs had reached half the limit and had put monitoring in its backlog (Mailchimp, 2019).
  • In 2021, Notion named stalled vacuuming and the threat of wraparound as the reasons it split its database into shards (Notion, 2021).

Watch age(datfrozenxid) for each database and age(relfrozenxid) for the largest tables, and alert long before the 200 million mark. Keep vacuuming fast enough with the same tuning as for dead rows, and keep transactions short, because a long one blocks freezing too. Fewer writing transactions also help: inserting 1,000 rows in one transaction uses one XID instead of 1,000, and COPY loads many rows in one statement far faster than separate inserts. A forgotten prepared transaction keeps its XID and blocks freezing; find it in pg_prepared_xacts. Multixacts, the IDs used when several transactions lock one row, have a counter and a limit of their own: watch mxid_age(datminmxid) as well (PostgreSQL).

Do not turn off autovacuum to save load on a busy database. Dead rows pile up, statistics go stale, and the vacuuming you skipped comes back as an anti-wraparound vacuum that does not yield, at the worst moment.

Two hosts edit the same listing, and two guests pay for the last free week at the same moment. IsolationIsolationHow much concurrent transactions are kept from seeing or overwriting each other's work. Each level, from read committed to serializable, blocks a different set of anomalies. Stronger levels cost waits or retries.Read more: Transactions: Seeing unfinished changes levels, locks, and retries handle such conflicts in any database (more on choosing an isolation level). How to use them in PostgreSQL depends on what each level name means there and how its locks work.

What each isolation level does in PostgreSQL:

  • Read committedRead committedThe isolation level at which each statement sees only committed data. It prevents dirty reads and dirty writes, and it is the default in most databases. Data can still change between two statements of one transaction.Read more: Transactions: Seeing unfinished changes, the default, takes a new snapshot for each statement. An UPDATE that waits for another transaction’s lock on a row then reads the committed row again and applies itself to it. So UPDATE homes SET views = views + 1 never loses an increment, but reading a value into the app and writing it back can.
  • Repeatable readRepeatable readThe isolation level of the SQL standard that rules out non-repeatable reads. Databases with row versions usually provide it as snapshot isolation, others with shared locks. What it allows differs between products.Read more: Transactions: Reading a consistent picture is snapshot isolationSnapshot isolationAn isolation level at which a transaction reads the data as it was when the transaction started. Later commits are invisible to it, so all its reads agree. It prevents read skew and lost updates, but not write skew.Read more: Transactions: Reading a consistent picture. When a transaction updates a row that another transaction changed and committed after the snapshot was taken, it fails with a serialization errorSerialization errorAn error by which the database aborts a transaction that conflicts with a concurrent one. The app must run the whole transaction again from the start.Read more: Transactions: Checking, then writing, and the app retries the whole transaction.
  • SerializableSerializableThe isolation level at which concurrent transactions end as if they had run one after another. It rules out every anomaly, at the cost of waits or retries. It covers only transactions that run at this level.Read more: Transactions: Running as if one at a time is SSISerializable snapshot isolationA way to get serializable isolation by reading from snapshots and aborting transactions whose reads and writes conflict. Reads never wait, but some transactions fail, including false alarms, and must be retried. Two writers of one row still wait as usual.Read more: Transactions: Running as if one at a time: PostgreSQL tracks which transaction read what another one wrote and aborts one of them when their order could not be serial. It catches write skewWrite skewTwo transactions that read the same data and write different rows, together breaking a rule that each one kept. No two writes touch the same row, so row locks and snapshot isolation miss it. A shared row that both write, a unique constraint, or serializable isolation stops it.Read more: Transactions: A rule across several rows without explicit locks; the price is more aborted transactions to retry and some memory to track reads. A long report can start with BEGIN ISOLATION LEVEL SERIALIZABLE READ ONLY DEFERRABLE: it may wait a moment for a safe snapshot, then runs without SSI tracking and without serialization failures.
  • Read uncommittedRead uncommittedThe lowest isolation level, which lets a transaction see uncommitted changes. It allows dirty reads and is rarely useful.Read more: Transactions: Seeing unfinished changes behaves as read committed.
Two updates of one counter under read committed and repeatable read Both panels: T1 and T2 run UPDATE homes SET views = views + 1 on a row with views = 10. T1 updates first, T2 waits for its row lock, and T1 commits. Left, read committed: T2 reads the committed row again and writes 12. Right, repeatable read: T2 fails with a serialization error and the app retries the whole transaction. Read committed T1 T2 UPDATE: 10 → 11 UPDATE: waits COMMIT reread 11, write 12 COMMIT views = 12 Repeatable read T1 T2 UPDATE: 10 → 11 UPDATE: waits COMMIT serialization error retry T2 retries

Row locks have four modes: FOR UPDATE, FOR NO KEY UPDATE (what an UPDATE takes when it does not change a key), FOR SHARE, and FOR KEY SHARE (what a foreign key check takes on the parent row). An insert of a review only blocks changes to the key of its home, not other updates of that home. PostgreSQL stores a row lock in the row itself, not in a table of locks in memory, so locking a million rows needs no memory per row, but it writes to their pages. NOWAIT fails at once instead of waiting for a lock, and SKIP LOCKED passes over locked rows (more on claiming any free rows).

A deadlockDeadlockTwo or more transactions that each wait for a lock another one holds, so none can go on. The database detects the cycle and aborts one transaction, which the app rolls back and retries. Taking locks in one fixed order prevents it.Read more: Transactions: Waiting for each other is found after deadlock_timeout (1 second), and PostgreSQL aborts one of the transactions. log_lock_waits logs every wait longer than that, which shows the queries that block each other.

An advisory lock is a lock on a number that the app chooses, for example the ID of a home whose calendar a worker rebuilds. PostgreSQL does not tie it to any row: only code that asks for the same lock respects it. pg_advisory_xact_lock holds it until the transaction ends; the session variants hold it until it is released or the connection closes, so they break behind a pooler in transaction mode.

A SAVEPOINT inside a transaction starts a subtransaction, and so does every PL/pgSQL block with an EXCEPTION clause. ORMs use savepoints to “find or create” a row. Subtransactions are costly: with more than 64 open subtransactions that wrote something in one transaction, their IDs no longer fit in that backend’s cache, and other sessions must look them up on disk pages (PostgreSQL). A long transaction keeps that state around for longer. GitLab traced slowdowns of its standbys to such savepoints next to a long transaction and removed every savepoint from its code in 2021 (GitLab, 2021). Where a unique key defines “the same row”, INSERT ... ON CONFLICT replaces a “find or create” savepoint.

More than 64 subtransactions A backend's cache holds the IDs of up to 64 subtransactions that wrote something. The 65th does not fit. Other sessions that check rows written by this transaction can no longer trust the cache and look the IDs up on pg_subtrans pages instead. BACKEND: CACHE OF 64 SUBTRANSACTIONS 65th DOES NOT FIT Other sessions pg_subtrans pages, read from disk CACHE INCOMPLETE LOOK UP EACH ID

Stay on read committed and make each write a single statement where you can (UPDATE ... SET x = x + 1, INSERT ... ON CONFLICT). Use FOR UPDATE when you read before writing, serializable when a rule spans rows you cannot lock, and retry on serialization errors and deadlocks.

The service adds instant booking: a new column instant_book on homes, a constraint, and an index, deployed in the middle of the day while 50 million homes are being searched and booked.

Most ALTER TABLE commands take an ACCESS EXCLUSIVE lock, which blocks even a plain SELECT. Adding a column holds it only for a moment, but first it has to get it. If a long query or a transaction left open still uses the table, the ALTER waits, and every new query on the table queues behind the waiting ALTER. A change that takes a millisecond stops all traffic to the table for as long as the oldest transaction runs. GoCardless lost 15 seconds of APIApplication Programming InterfaceA contract that lets one program use another program. An API says which requests a program accepts and what it returns. In system design, it usually means the network endpoints that clients and other services call.Read more: Wikipedia: API traffic this way on an older PostgreSQL version, where adding a foreign key still took this lock on the busy table it referred to, and the lock queued behind a long read (GoCardless). Since PostgreSQL 9.5, a new foreign key takes a weaker lock that lets reads go on, but it still blocks writes and waits behind them.

Set lock_timeout in every migration, to a few seconds, so the ALTER gives up instead of blocking everyone, and retry it a moment later. OpenAI applies a 5-second timeout to every schema change (OpenAI, 2026). Each form of ALTER TABLE takes its own lock; the documentation lists them.

An ALTER TABLE waiting for its lock, without and with lock_timeout Both panels: a long report reads the table, and a migration runs ALTER TABLE, which needs an exclusive lock. Left, without lock_timeout: the ALTER waits for the report, and new reads queue behind the ALTER until the report ends. Right, with lock_timeout = 3s: the ALTER gives up after 3 seconds, the queued reads run, and the ALTER is retried after the report ends. no lock_timeout REPORT ALTER NEW READS lock_timeout = '3s' REPORT ALTER NEW READS SELECT SELECT waits ALTER queued run waits 3 s ALTER run reads blocked until the report ends reads wait at most 3 s

Some changes rewrite the whole table and hold the lock for all of that time:

Change Cost on a large table
ADD COLUMN of a plain type with no default, or with a non-volatile default such as 0 or now() A moment: since PostgreSQL 11, the default is stored once, not in every row
ADD COLUMN with a volatile default, such as random() or clock_timestamp(), an identity column, or a stored generated column Rewrites the table
ALTER COLUMN ... TYPE Usually rewrites the table and its indexes; widening varchar(n) or changing it to text does not
SET NOT NULL Scans the table, unless a validated CHECK (col IS NOT NULL) already proves it
ADD CONSTRAINT (check or foreign key) Scans the table under the lock; split it into NOT VALID, then VALIDATE CONSTRAINT, which lets reads and writes go on
ADD CONSTRAINT ... UNIQUE or PRIMARY KEY Builds an index under the lock; build a unique index CONCURRENTLY first, then add the constraint USING INDEX
CREATE INDEX Blocks writes while it builds; use CONCURRENTLY
Adding a check constraint in one step and in two Left, ADD CONSTRAINT with a check scans the whole table under an exclusive lock, and reads and writes wait for the whole scan. Right, ADD CONSTRAINT ... NOT VALID takes the lock only for a moment, and VALIDATE CONSTRAINT then scans the table while reads and writes go on. ADD CONSTRAINT MIGRATION READS WRITES NOT VALID, then VALIDATE MIGRATION READS WRITES scan wait run wait run NOT VALID VALIDATE run run

Railway forgot the last row in 2025: an index on a table of about 1 billion rows, built without CONCURRENTLY, locked a table that almost every API call used, for about 30 minutes (Railway, 2025). The team said it would make its CI reject such migrations. Linters such as Strong Migrations for Rails catch these cases before deploy.

Filling the new column for existing rows is a separate step. Run it outside the transaction that changes the schema, in batches of a few thousand rows with pauses between them: each batch writes new row versions and WAL, which feeds dead rows and standby lag, and holds row locks while it runs. OpenAI rate-limits its backfillsBackfillA job that fills a new table or field from the data that already exists. It is needed when a new query gets its own table, and it can take hours on large data.Read more: Databases: Designing from the queries so strictly that one can take more than a week (OpenAI, 2026). Renaming or dropping a column in use needs the app to stop using it first.

Give every migration a lock_timeout and a retry, build indexes with CONCURRENTLY, add check and foreign key constraints as NOT VALID and validate them later, and backfill in small batches outside the schema change.

After each booking, the service sends a confirmation email and resizes the photos of new listings. The usual tool for such work is a message broker, a separate system to deploy and run. PostgreSQL can hold the queue itself, with one advantage over a broker: a job can commit in the same transaction as the booking that created it. Either both exist or neither does, with no booking whose email was lost between two systems.

A queue is a table of jobs, with locked_until set in the past for a new job. A worker claims a batch and sets a leaseLeaseA right, such as a lock, that lasts a set time and must be renewed. When the holder stops renewing it, the lease runs out and another holder can take over. A lease that guards outside work needs a fencing token or work that is safe to repeat, since a paused holder may still act.Read more: Consistency: Committing through a majority, so that no other worker takes those jobs while the lease lasts:

UPDATE jobs
SET locked_until = now() + interval '5 minutes',
attempts = attempts + 1
WHERE id IN (
SELECT id
FROM jobs
WHERE run_at <= now()
AND locked_until < now()
ORDER BY run_at
LIMIT 10
FOR UPDATE SKIP LOCKED
)
RETURNING id, kind, payload, attempts;
-- 88121 | email | {"booking_id": 51077} | 1
-- 88124 | resize | {"home_id": 1042} | 1
Three workers claim jobs with SKIP LOCKED Nine jobs in run_at order. Worker A claims the first three and locks them. Worker B reads from the top, skips the three locked jobs, and claims the next three. Worker C skips six and claims the last three. No worker waits for another. JOBS, BY RUN_AT 88121 · email 88122 · resize 88123 · email 88124 · email 88125 · resize 88126 · email 88127 · resize 88128 · email 88129 · email WORKER A WORKER B WORKER C B C

SKIP LOCKED lets many workers claim different jobs at once without waiting for each other. After the work is done, the worker deletes the job, but only if the job still carries its claim: WHERE id = $1 AND attempts = $2. If the worker dies or runs past its lease, another worker takes the job again, and this check stops the first worker from deleting the second one’s claim. Either way a job can run twice: make each handler safe to repeat, for example with an idempotency keyIdempotency keyA unique ID the client attaches to a request, so the server can recognize a retry of it. If the same key comes again, the server returns the saved result instead of doing the work twice. Used for payments and orders.Read more: Networking: When a call fails. Order is approximate, because a slow job does not hold back the ones after it. The pgmq extension packs this pattern into functions much like a cloud queue service, and pg_cron runs SQL on a schedule inside the database, such as releasing unpaid bookings every minute.

A lease that runs out, and the claim check on delete Worker A claims job 88121, which sets attempts to 1 and a lease until 10:05. A runs past its lease, so at 10:05 worker B claims the job again, with attempts 2. When A finishes, its delete asks for attempts = 1 and matches no row. B's delete with attempts = 2 removes the job. WORKER A JOB 88121 WORKER B CLAIM attempts 1, until 10:05 working 10:05 LEASE ENDS CLAIM attempts 2, until 10:10 DELETE, ATTEMPTS = 1 0 ROWS DELETE, ATTEMPTS = 2 1 ROW

Workers either poll the table every second or so, or wait for a signal. LISTEN and NOTIFY send such a signal, but only as a wake-up: a notification goes only to sessions listening at that moment, a worker that was disconnected misses it, and a listener needs its own session, not one shared through a pooler. A NOTIFY also takes a lock on the whole instance at commit, so the commits of transactions that notify run one at a time. Recall.ai, with tens of thousands of concurrent writers, had three outages in four days in 2025 because of this and moved the signal into the app (Recall.ai, 2025).

Each job is an insert, an update, and a delete, so a busy queue table is mostly dead rows, and it depends on vacuuming more than any other table; an old snapshot anywhere in the database slows every worker. Claiming jobs writes, so only the primary can serve them, and the queue competes with bookings for the same server.

Move the queue to a broker when one message has many independent consumers, when consumers need to replay old messages, when other teams subscribe to the events, or when the job traffic is large enough to slow the main workload.

Keep background jobs in PostgreSQL while one database serves the app: enqueue in the business transactionBusiness transactionA user's action that spans several requests and minutes, such as a checkout. Each step runs as a short database transaction. A claim that must last across the steps is stored in the data, not in an open transaction.Read more: Transactions: Work that outlives a transaction, claim with SKIP LOCKED and a lease, make handlers safe to repeat, and use NOTIFY only as a wake-up, if at all.

Guests type “quiet flat near the beach” into the search box. Without a fitting index, LIKE '%beach%' reads every description. It also matches “beachfront”, and it cannot rank the results by how well they fit. PostgreSQL has full-text search with an inverted indexInverted indexAn index from each word to the list of records that contain it. It finds records by words inside text, which a B-tree index cannot do.Read more: Databases: Searching by words built in (more on search by words), so for many products the database already holds the search.

A tsvector is a document turned into words in a standard form, called lexemes, with their positions; with the English configuration, “beaches” becomes “beach”. Store it as a generated column and index it with GIN, giving words in the title more weight than words in the description:

ALTER TABLE homes
ADD COLUMN search tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(description, '')), 'B')
) STORED;
CREATE INDEX CONCURRENTLY ON homes USING gin (search);

On a large live table, add a plain column instead of a generated one, which would rewrite the table, keep it current with a trigger, and fill the old rows in batches. The language configuration ('english') decides how words become lexemes and which common words are dropped; it must be the same when indexing and when searching, and listings in several languages need the language in a column of their own. Turn what the user types into a query with websearch_to_tsquery, which accepts quotes, or, and a minus sign the way web search boxes do:

SELECT id, title
FROM homes, websearch_to_tsquery('english', 'quiet flat beach') AS q
WHERE city = 'Lisbon'
AND search @@ q
ORDER BY ts_rank(search, q) DESC
LIMIT 20;
-- 3317 | Quiet flat by Carcavelos beach
-- 1289 | Beach studio with a quiet terrace

The GIN index finds the matching rows fast, but it stores no positions or weights, so ts_rank reads the tsvector of every match from the table. Ranking 100,000 matches to show 20 is slow; narrow the matches first, as the city filter does here. Every change to a title or description updates many entries of the GIN index, which makes writes slower than with a B-tree.

Ranking reads the tsvector of every match Top: the GIN index finds 100,000 homes that match beach, flat, and quiet, and ts_rank reads the tsvector of each of them from the table to pick the top 20. Bottom: with city = Lisbon in the same query, 800 homes match, and ranking reads 800 tsvectors. SEARCH WORDS ONLY GIN index beach, flat, quiet Matches 100,000 rows ts_rank reads 100,000 Top 20 WITH city = 'Lisbon' GIN index + city filter Matches 800 rows ts_rank reads 800 Top 20

Built-in search has limits. It does not forgive typos: the pg_trgm extension compares strings by their three-letter pieces, which finds “Lisbn”, serves autocomplete, and gives LIKE '%beach%' an index. Its ranking counts how often and how close the words appear, without the BM25 scoring of search enginesSearch engineA separate store built for full-text search, such as Elasticsearch or OpenSearch. It adds language analysis, tuned relevance, and facets. In most designs it holds a copy of data owned elsewhere, which lags behind.Read more: Databases: Searching by words; extensions such as pg_search add BM25. PostgreSQL can count results by category with SQL and has dictionaries for synonyms. Move search to a search engine when relevance tuning, per-language analysis, fast counts by category next to the results (facets), or machines of its own for search load cost more to build in PostgreSQL than to run another system.

Start search inside PostgreSQL: a stored tsvector with weights, a GIN index, websearch_to_tsquery, and pg_trgm for typos. Filter before you rank. Move to a search engine when tuned relevance, per-language analysis, or facets at your load become too costly to build in PostgreSQL.

The map view shows homes within 5 km of a point, nearest first, and the homes inside the part of the map on the screen. A B-tree index on latitude can narrow the search to one band of the map, but then it checks the longitude of every home in that band. Points on a plane need an index of their own.

The PostGIS extension adds types, functions, and indexes for places. Store a home’s location as geography(Point, 4326): a point given as longitude, then latitude, with distances in meters on the curved surface of the Earth. The geometry type computes on a flat plane, which is faster but distorts distances unless the data uses a projection made for the area. A GiST index stores a bounding box for each shape and finds the boxes near the query first:

CREATE INDEX CONCURRENTLY ON homes USING gist (location);
SELECT id, title
FROM homes
WHERE ST_DWithin(location, ST_MakePoint(-9.14, 38.71)::geography, 5000)
ORDER BY location <-> ST_MakePoint(-9.14, 38.71)::geography
LIMIT 20;
-- 1042 | Villa with a pool in Belém (nearest first)
-- 5120 | Loft near Rossio
Homes within 5 km of a point, nearest first A point on the map with a circle of 5 km around it. Four homes lie inside the circle and are returned nearest first: 1042, 5120, 3317, 2210. Homes outside the circle are not returned. Dashed boxes are the bounding boxes in the GiST index that the search reads first. 5 KM 1 2 3 4 RESULT 1 1042 2 5120 3 3317 4 2210

Only some functions use the index. ST_DWithin does; ST_Distance(...) <= 5000 means the same thing, but it computes the distance to every row. In a test on an OpenStreetMap extract with flat coordinates and a 500 m radius, the first form took 1.1 ms and the second 488 ms (Magda). The <-> operator returns the nearest rows straight from the index. For geography it measures on a sphere, which can order nearly equal distances slightly differently from ST_Distance; when exact order matters, keep the ST_DWithin filter, order all rows it finds by ST_Distance, and apply the LIMIT after that (PostGIS). ST_Covers(area, location) finds the homes inside a drawn area, such as a neighborhood, including those on its border. Distance in a straight line is not travel time; routes need the pgRouting extension or a routing service.

ST_DWithin and ST_Distance on the same homes The same homes and the same circle in both panels. Left, ST_DWithin: the GiST index finds the index boxes that touch the circle, and only the homes in them are checked. Right, ST_Distance(...) <= 5000 computes the distance to every home. In a test, the first took 1.1 ms and the second 488 ms. ST_DWithin 1.1 ms ST_Distance(...) <= 5000 488 ms

PostGIS fits places that change rarely, like homes. Objects that report their position every few seconds, such as drivers, are a different load: each report updates an indexed column, so the update cannot be HOT, and the index and the dead rows churn. When that write load costs the primary more than it can spare, keep such points in an in-memory index outside the database.

Use PostGIS with geography points and a GiST index for searches around a point or inside an area, and write queries with ST_DWithin, <->, and ST_Covers so they use the index. Measure the write load of fast-moving positions before you keep them in the main tables.

“Similar homes” should find homes like the one a guest is viewing, and a search for “cozy place to work remotely” should find a listing described as “quiet loft with a big desk and fast Wi-Fi”. This is search by embeddingEmbeddingA vector of numbers that a model computes from a text or an image. Similar meanings get vectors close to each other, so a nearest-neighbor search finds similar items. Vectors from different models cannot be compared.Read more: Databases: Searching by meaning vectors, and the pgvector extension keeps them in PostgreSQL next to the listing (more on search by meaning).

A vector of 1,024 numbers takes about 4 KB per row. Exact search compares the query with every vector and returns the true nearest ones; it is the right choice for small sets and for queries whose filters leave few rows. For more, pgvector has two approximate indexes:

  • HNSW builds a graph of neighbors. It gives better recallRecallThe share of the true nearest neighbors that an approximate search returns. Approximate indexes trade recall for speed and memory.Read more: Databases: Searching by meaning and speed for a given amount of work, can be built on an empty table, and keeps up as rows are added. It builds slowly and takes a lot of memory; the build is much faster when the graph fits in maintenance_work_mem.
  • IVFFlat splits the vectors into clusters when it is built and searches only the clusters closest to the query (ivfflat.probes). It builds faster and takes less memory, but it must be built after the data is loaded, and its recall drops as new data moves away from the original clusters, so it needs rebuilds.
How HNSW and IVFFlat find the nearest vectors The same sixteen vectors and the same query in both panels. Left, HNSW: a graph of neighbors; the search walks from an entry point along the edges toward the query and finds the nearest vector. Right, IVFFlat: the vectors are split into four clusters; with probes = 1 the search reads only the cluster whose center is closest to the query and misses the nearest vector, which lies in the next cluster. HNSW ENTRY QUERY IVFFlat, probes = 1 NEAREST, NOT READ READ QUERY

Both trade recall for speed with a setting per query (hnsw.ef_search, 40 by default, and ivfflat.probes, 1 by default). Measure recall on real queries against exact search before you pick the values. Indexes accept vectors of up to 2,000 numbers, or up to 4,000 with halfvec, which stores each number in half the space (pgvector).

Filters need care. An approximate index first finds the nearest candidates and only then applies the WHERE clause. With the default ef_search of 40, a filter that matches 10% of homes leaves about 4 results instead of 20. Since pgvector 0.8, iterative index scans read further into the index until enough rows pass or a scan limit is reached. Turn them on with SET hnsw.iterative_scan = strict_order; the relaxed mode, the only one for IVFFlat, can return rows slightly out of order, so sort them again by distance. A filter on a few values can also use a partial index, one per value (pgvector).

A filter applied after the approximate search Top, the default: HNSW returns 40 nearest candidates (ef_search = 40), and a filter that matches 10% of homes keeps 4 of them, so the query returns 4 rows instead of 20. Bottom, with an iterative scan: the index keeps reading candidates, here 200, until 20 rows pass the filter. EF_SEARCH = 40 4 rows ITERATIVE SCAN 20 rows

Before adding another system, try smaller vectors (halfvec, fewer dimensions) and index settings. Move vectors to a dedicated vector database when the index still does not fit the memory you can give it, when you need to split it across machines, or when filtered searches at your scale do not reach the recall you need.

Start with pgvector next to the data: exact search while the set or the filtered candidates are small, an HNSW index when exact search is too slow. Measure recall against exact search, turn on iterative scans for filtered queries, and plan memory for the index.

Caches, counters, and time series inside PostgreSQL

Section titled “Caches, counters, and time series inside PostgreSQL”

Every price on the site is shown in the guest’s currency, and the rates come from a paid outside service. Hosts want a dashboard with views and bookings per day, and the team wants a history of each home’s nightly price. Each of these usually brings in another system: a cache, a counter store, a time-series databaseTime-series storeA store built for measurements over time, typically appended in time order and queried over recent windows. Many such stores keep data in time blocks, so old data is dropped cheaply, and some compute rollups for long history.Read more: Databases: Measuring over time. PostgreSQL covers much of it while the extra load stays small next to the main work.

An unlogged table (CREATE UNLOGGED TABLE) skips the WAL, so its writes are much cheaper. After a crash or an unclean shutdown it comes back empty, and standbys get none of its contents. That is fine for a cache: store each value with an expires_at column, ignore expired rows when reading, and delete them on a schedule with pg_cron. Such a table suits the results of slow or paid calls, such as exchange rates. It does not take load off the database: each read is still a query to the same server, and every expired row becomes a dead row to vacuum. When the goal is to protect the database from repeated reads, or reads must take well under a millisecond, put an in-memory cache such as Redis in front of it (more on a cache in front of the database).

A materialized view stores the result of a query as a table. The host dashboard reads daily totals from it instead of scanning bookings on every page load. REFRESH MATERIALIZED VIEW runs the whole query again and blocks reads of the view while it runs. With CONCURRENTLY, reads go on, but the view must already hold data and needs a unique index on plain columns that covers all rows. It compares the new result with the old one, so it often does more work, though it can be faster when little changed. Core PostgreSQL has no incremental refresh, so each refresh costs the full query: refresh on a schedule that matches how fresh the numbers must be.

A view counter in one row makes every view wait for the lock of the one before (more on why an update is costly). Insert a row for each event instead, or for each home and minute, and let a job add them into the counter in batches. In a test where 100 workers each recorded 10 events, updates of one shared row took 3.10 s, and inserts of separate rows 2.13 s (Fontaine). Totals have a cost of their own: PostgreSQL keeps no exact row count for a table, because each transaction may see a different number of rows, so count(*) reads the whole table or an index. On 10 million rows, an exact count took 400 ms to 3.5 s depending on the cache, while the planner’s estimate in pg_class.reltuples took 3.7 ms (Atkinson). Show “about 10 million homes” from the estimate, or keep exact totals in a counter table.

One counter row and appended event rows Left: every view updates one row, so the workers wait for its lock one after another. Right: every view inserts a row of its own, the inserts do not wait for each other, and a job adds them into the counter. In a test with 100 workers, the single row took 3.10 s and the separate rows 2.13 s. One row worker 1 worker 2 worker 3 worker 100 views = 41 ONE LOCK 3.10 s A row per view worker 1 worker 2 worker 3 worker 100 1042 · +1 1042 · +1 1042 · +1 1042 · +1 views = 45 JOB 2.13 s

Price history and search events are written once, in time order, and read by time ranges. A B-tree on the timestamp works, but it grows with the table. A BRIN index stores a summary of each range of pages, by default the lowest and highest value, so it stays tiny. In a TimescaleDB example, BRIN indexes on the time column of single time chunks took 24 KB each, while B-tree indexes on time and device of such chunks took 1.4 or 3.9 MB (Magda). It helps only while the order of rows on disk follows time: late inserts and updates widen the ranges, and the index skips fewer pages. Old data goes away by dropping whole partitions (more on splitting one big table). The TimescaleDB extension creates time partitions by itself and adds compression and continuous aggregates, which keep hourly or daily totals current. Move to a dedicated time-series database when the measured ingest and query load of metrics competes with the main work, or when you need rollupsRollupA precomputed summary of detailed data at a lower resolution, such as one point per hour. It keeps long history affordable and makes repeated numbers cheap to show.Read more: Databases: Measuring over time and retentionRetentionHow long data is kept before it is deleted. Stores that keep data in time blocks drop a whole expired block at once, far cheaper than deleting rows one by one.Read more: Databases: Measuring over time per series that PostgreSQL and TimescaleDB make costly.

A BRIN index before and after a late insert Five ranges of four pages each, with the lowest and highest date in each range. Top: rows arrive in time order, and a query for 2 May reads only the first range. Bottom: a row dated 2 May was inserted late into the last range, which now covers 2 to 15 May, so the same query reads two ranges. QUERY: 2 MAY, ROWS IN TIME ORDER 1–3 May 4–6 May 7–9 May 10–12 May 13–15 May QUERY: 2 MAY, AFTER A LATE INSERT 1–3 May 4–6 May 7–9 May 10–12 May 2–15 May LATE ROW: 2 MAY

Use an unlogged table for a cache of slow or paid calls, a materialized view for totals that may be minutes old, appended rows for hot counters, and BRIN with time partitions for history. Add an in-memory cache when reads must leave the database, and a time-series database when metrics compete with the main work.

The service must survive the loss of its database server, and search traffic has grown beyond what one server can read. Both need copies of the data on other servers (more on keeping a copy on another node).

PostgreSQL calls the leaderLeaderThe replica that accepts writes and decides their order. It sends each change to the followers through the replication log. Products also call it the primary or the source.Read more: Replication: Keeping a copy on another node the primary and the followersFollowerA replica that applies the changes of a leader in the same order. It serves reads and can become the leader after a failover. Products also call it a secondary, a standby, or a read replica.Read more: Replication: Keeping a copy on another node standbys. With streaming replication, the primary sends its WAL to each standby over a connection as it is written, and the standby replays it. This is physical replicationPhysical replicationReplication that sends the changed bytes of pages from the WAL. Followers become exact copies, but every replica needs the same storage format, usually the same major version, so a major upgrade usually needs downtime.Read more: Replication: Choosing what the log carries: a standby is a byte-for-byte copy of the whole instance, with every database in it. It must run the same major version on the same platform, and it accepts only reads (a hot standby). A new standby starts from a copy of the primary’s files taken with pg_basebackup, then asks for the WAL after that point.

ReplicationReplicationKeeping a copy of the same data on several nodes. Copies let the data survive the loss of a node, spread reads, and sit close to users. The hard part is keeping them in step while the data changes.Read more: Replication: Keeping a copy on another node is asynchronous by default. To have commits wait for standbys, list them in synchronous_standby_names, and synchronous_commit decides what the commit waits for:

synchronous_commit The commit returns when the WAL is
off Not waited for at all; flushed on the primary within about 0.6 s with default settings
local Flushed on the primary
remote_write Flushed on the primary and handed to the operating system on the synchronous standbys; a crash of a standby’s machine can still lose it
on, the default Flushed on the primary and on the synchronous standbys
remote_apply Flushed on the primary and on the synchronous standbys, and replayed there, so new queries on those standbys see it

When synchronous_standby_names is empty, on, remote_write, and remote_apply act like local (PostgreSQL). Set the level per transaction: a payment waits for a standby, a view counter does not. If no synchronous standby answers, commits wait forever, so list more than the number you wait for: ANY 1 (s1, s2) waits for whichever of two standbys answers first, which takes three servers in total.

Where a commit returns for each synchronous_commit level The WAL of a commit goes through five steps: flushed on the primary, sent, written to the operating system on the synchronous standby, flushed there, and replayed there. off returns before the first step, local after the flush on the primary, remote_write after the write on the standby, on (the default) after the flush on the standby, and remote_apply after the replay. PRIMARY NETWORK SYNCHRONOUS STANDBY flushed sent written to OS flushed replayed off local remote_write on DEFAULT remote_apply

A standby lags behind, and pg_stat_replication on the primary shows by how much at each step: sent, written, flushed, and replayed. Reads see a write only after replay. For a user who must see their own write, read from the primary, or note the primary’s WAL position (pg_current_wal_lsn()) after the write and read from a standby only once its pg_last_wal_replay_lsn() has reached it, in a new snapshot (more on seeing your own writes). remote_apply helps only on the standbys that acknowledged the commit: with ANY 1 of two, the other one may still be behind.

Reading your own write from a standby The app commits a write on the primary and notes its WAL position, 0/3A1F. It asks the standby how far it has replayed: 0/39F0, still behind, so the app waits. The WAL up to 0/3A1F reaches the standby. The next check returns 0/3A1F, and the app reads from the standby and sees its write. APP PRIMARY STANDBY COMMIT LSN 0/3A1F REPLAYED LSN? 0/39F0: BEHIND wait WAL TO 0/3A1F REPLAYED LSN? 0/3A1F SELECT

Queries on a standby can conflict with replay. Vacuuming on the primary removes row versions that a long query on the standby still reads. The standby then delays replay, by up to 30 seconds in total by default (max_standby_streaming_delay), and then cancels the query; a query that comes after earlier delays may get much less time. With hot_standby_feedback = on, the standby tells the primary which versions it still needs, and such conflicts stop, but dead rows pile up on the primary as if the long query ran there (more on dead rows). Other conflicts, such as a schema change replayed from the primary, can still cancel queries; pg_stat_database_conflicts counts them. Give long reports a standby of their own with a longer delay, and keep feedback off on the standbys that must stay current.

A query on a standby and vacuuming on the primary Both panels: a long query on the standby reads row version v1. Left, hot_standby_feedback off: vacuuming on the primary removes v1, the standby delays replaying that change for up to 30 seconds, and then cancels the query. Right, feedback on: the standby tells the primary it still needs v1, vacuuming keeps it, and the query finishes; the dead row stays on the primary until then. hot_standby_feedback = off PRIMARY STANDBY query on v1 vacuum: drop v1 REPLAY REPLAY WAITS 30 S query cancelled hot_standby_feedback = on PRIMARY STANDBY query on v1 NEED V1 vacuum keeps v1 query finishes

The primary keeps WAL only as long as it needs it. A standby that was down too long finds the WAL it needs deleted and must fetch it from the WAL archive, if there is one, or start from a new copy. A replication slot makes the primary keep the WAL that its consumer still needs. The price is that a standby that is gone for good makes WAL pile up until the disk fills and the primary stops. Set max_slot_wal_keep_size (PostgreSQL 13, unlimited by default): at a checkpoint, a slot that holds more WAL than that is invalidated, and its standby needs the archive or a new copy. Alert on the WAL that each slot holds in pg_replication_slots.

A replication slot holds WAL for a stopped standby Twelve WAL segments on the primary. Top: standby s2 stopped at segment 3, and its slot keeps segments 3 to 12 on disk. Bottom: the WAL kept for the slot grew past max_slot_wal_keep_size, so at a checkpoint the slot is invalidated, segments 3 to 6 are removed, and s2 must catch up from the archive or start from a new copy. SLOT OF STOPPED STANDBY S2 1 2 3 4 5 6 7 8 9 10 11 12 AT A CHECKPOINT 1 2 3 4 5 6 7 8 9 10 11 12 KEPT FOR SLOT S2 max_slot_wal_keep_size S2 INVALIDATED

Every standby adds work on the primary: one process and one copy of the WAL stream each. OpenAI serves ChatGPT from one primary and nearly 50 standbys in many regions, and it named the cost of streaming WAL to all of them as a limit. It is testing cascading replication, where standbys feed other standbys, to grow past 100 (OpenAI, 2026).

Run at least one standby in another availability zoneAvailability zoneOne of several separate failure areas in a cloud region, with one or more data centers. Zones are close enough for synchronous replication. Replicas spread over zones survive the loss of a zone, but not of the whole region.Read more: Replication: Deciding when a write is done. Make commits that must survive the loss of the primary wait for a quorumQuorumThe number of replicas that must answer a read or a write. With N replicas, W write answers, and R read answers, W + R > N makes the read and write sets overlap, so a read usually reaches the latest confirmed write. It is not a strong guarantee.Read more: Replication: Writing without a leader such as ANY 1 of two standbys, and leave the rest asynchronous. Use slots with max_slot_wal_keep_size set and a WAL archive to catch up from, and keep long reports on a standby of their own.

At night, the primary’s machine stops. Standbys still serve searches, but nobody can book.

PostgreSQL has no built-in failoverFailoverMoving work to another instance when the active one fails. In replication, a follower that has every confirmed write becomes the leader. A timeout only suspects the failure, so the old leader must be fenced.Read more: Replication: Replacing a failed leader. pg_promote() turns a standby into a primary, and nothing more: it does not stop the old primary, does not move the clients, and does not point the other standbys at the new primary. The steps of failover and their risks are the same in every database (more on replacing a failed leader); for PostgreSQL, a separate tool carries them out.

The common choice is Patroni. It runs next to each PostgreSQL server and keeps a leader key in a coordination serviceCoordination serviceA small consensus group, such as etcd, ZooKeeper, or Consul, that other systems use for locks, leaders, and configuration. It stores small keys with linearizable writes, leases, and watches. It is meant for shared metadata; app data belongs in a database that spreads it over many groups.Read more: Consistency: Letting only one node act such as etcd, with a time to live, 30 seconds by default. The server whose Patroni holds the key is the primary and renews the key every 10 seconds. When the key expires, the standbys race for it, and only one close enough to the old primary in WAL may take it and promote itself. A primary that cannot renew the key demotes itself. That alone is not enough: a paused or stuck Patroni may fail to demote in time, so turn on its watchdog, which restarts the machine when Patroni stops responding, or another way to fence the old primary (more on letting only one node act). On Kubernetes, operators such as CloudNativePG do the same job.

Patroni on three servers with etcd Three nodes, each with Patroni, a watchdog, and PostgreSQL. The leader key in etcd names node 1 and has a time to live of 30 seconds. Patroni on node 1 renews it every 10 seconds, and node 1 runs the primary; Patroni on nodes 2 and 3 watches the key, and they run standbys. The app lists all hosts with target_session_attrs=read-write and connects to the primary. etcd leader key = node 1, TTL 30 s NODE 1 Patroni watchdog PostgreSQL primary RENEW EVERY 10 S NODE 2 Patroni watchdog PostgreSQL standby WATCH NODE 3 Patroni watchdog PostgreSQL standby WATCH App target_session_attrs=read-write
The leader key expires after the primary stops Node 1 renews the leader key at 0, 10, and 20 seconds. At 25 seconds it stops. The key lives 30 seconds from the last renewal and expires at 50 seconds. Node 2, a standby close enough to the old primary in WAL, takes the key and promotes itself. NODE 1 ETCD NODE 2 RENEW 0 S RENEW 10 S RENEW 20 S 25 S STOPS TTL 30 S 50 S EXPIRED TAKE KEY promote

After a failover, the old primary may hold writes that never reached the new one. It cannot simply follow the new primary: pg_rewind brings it back in line by copying the pages that differ, which needs data checksums or wal_log_hints, or it is rebuilt from a fresh copy. With asynchronous replicationAsynchronous replicationReplication where confirming a write does not wait for followers. Writes are fast and do not depend on followers, but a confirmed write can be lost if the leader fails before a follower gets it.Read more: Replication: Deciding when a write is done, those writes are lost. Commits made with synchronous replicationSynchronous replicationReplication where the leader confirms a write only after a follower has saved it. A confirmed write survives the loss of the leader if failover promotes a follower that has it. Every write waits for the follower, and writes stop when it fails.Read more: Replication: Deciding when a write is done survive only if the promoted standby was one that confirmed them: Patroni’s synchronous_mode promotes only such a standby. When no synchronous standby is left, Patroni by default lets the primary take writes alone and then refuses an automatic failover that could lose them; synchronous_mode_strict makes writes wait for a standby instead. For a quorum such as ANY 1 of two, use Patroni’s synchronous_mode: quorum rather than setting synchronous_standby_names by hand. Transactions that set synchronous_commit to local or off get no such promise (Patroni).

Clients must find the new primary. Give drivers a list of hosts with target_session_attrs=read-write, so they connect only to the server that takes writes. Or put a proxy in front that asks Patroni which server is the primary: a portPortA number that identifies a program on a machine, so one IP address can serve many programs. A connection goes to an IP address and a port, for example port 443 for HTTPS. L4 load balancers route traffic by IP address and port.Read more: Networking: Finding the server check passes on every running server, standbys included. Point the pooler at whatever routing you choose.

Managed services, such as Amazon RDS, Azure Database for PostgreSQL, and Google Cloud SQL, exist because of this gap. When you choose their high-availability option and set the backup and maintenance policies, they run the standby, the failover, the routing by DNSDomain Name SystemThe system that turns a domain name into an IP address. Before a client can connect to example.com, it asks DNS for the address. Answers are cached for the time set by their TTL. DNS can also spread users across servers or regions by returning different addresses.Read more: Networking: Finding the server name, the backups with point-in-time recoveryPoint-in-time recoveryRestoring a database to a chosen moment covered by a snapshot and an unbroken archived log. Restore the last snapshot before a mistake and replay the log up to just before it.Read more: Replication: Undoing a bad write, and minor upgrades. The price is money and control: no superuser, a fixed list of extensions and settings, and failover times the provider sets, typically 60–120 seconds for an RDS instance with a standby in another zone (AWS). Amazon Aurora goes further and replaces PostgreSQL’s storage: it writes the log to six copies in three zones and counts a write as done on four of them, and its read servers use the same storage instead of keeping copies of their own (Verbitski et al., 2017). Failover then copies no data, though recovery and reconnecting clients still take time, and the storage layer is no longer PostgreSQL’s, so check that it behaves the way your design assumes.

Amazon Aurora storage Three availability zones. The writer runs in zone 1, and read servers in zones 2 and 3. They share one storage volume with six copies, two in each zone. A write counts as done when four of the six copies have it. ZONE 1 Writer copy 1 copy 2 ZONE 2 Reader copy 3 copy 4 ZONE 3 Reader copy 5 copy 6 SHARED STORAGE VOLUME WRITE DONE: 4 OF 6 COPIES

Use a managed service unless you need control it does not give. When you run PostgreSQL yourself, use Patroni with a watchdog or a Kubernetes operator with three servers, route clients by role rather than by a port check, and practice a failover before you need one.

A release deletes the calendars of 10,000 homes. Within a second, every standby deletes them too (more on undoing a bad write). Getting them back needs a copy from before the mistake, and PostgreSQL has two kinds.

pg_dump writes a logical copy of one database: the schema and the rows, read from one snapshot while the database keeps working. It loads into another version or another machine, and one table can be restored from it. Restoring runs every insert again and rebuilds every index, so a large database takes hours. A dump holds one moment: everything written after it is lost, and roles and other instance-wide settings need pg_dumpall --globals-only.

A physical backup copies the files of the whole instance (pg_basebackup), and a WAL archive keeps every WAL segment after it, for example in object storageObject storageA store of files under keys in one flat namespace, read and written whole over HTTP. It is cheap, grows without limit, and keeps files durable. Amazon S3 is the best-known one.Read more: Databases: Storing files (archive_command). Together they give point-in-time recovery: restore the files and replay the WAL up to a chosen moment, such as one second before the bad release (recovery_target_time). The recovered server then starts a new timeline of WAL, so it never mixes its history with the old one. A physical backup restores the whole instance only: to get one table back, restore it on another server and copy the rows over.

Point-in-time recovery and a new timeline Timeline 1: a base backup on Sunday at 02:00, then WAL archived continuously. On Tuesday at 14:02 a bad release deletes calendars. Recovery restores the base backup on another server and replays the archived WAL up to 14:01:59. The recovered server then takes writes on a new timeline 2. SUN 02:00 TUE 14:01:59 TUE 14:02 TIMELINE 1 base backup WAL WAL WAL WAL WAL WAL ARCHIVE BAD RELEASE restored copy RESTORE REPLAY WAL TIMELINE 2: NEW WRITES

Tools such as pgBackRest and Barman run this for you: parallel and compressed backups, incremental ones, retention, checks, and storage in object storage. Since PostgreSQL 17, pg_basebackup can take incremental backups as well; pg_combinebackup joins them with the full backup before a restore. Watch the archive: when archive_command fails, the primary keeps the WAL it has not archived, and the disk fills.

A backup that nobody restored is a guess. In GitLab’s 2017 outage, the nightly pg_dump had used version 9.2 tools against a 9.6 server and failed every time, and its error emails were rejected, so nobody knew (GitLab, 2017). Check each backup with pg_verifybackup or the checks of your backup tool. Then restore regularly onto another server, replay the WAL to a chosen moment, run real queries against the result, and time the whole restore, because that time is the floor of your recovery time for this failure. A standby that replays an hour late (recovery_min_apply_delay) gives a quicker way back from a mistake noticed within that hour, but it does not replace backups.

Back up the instance with a physical backup and a continuous WAL archive, kept away from the database servers, with a tool such as pgBackRest. Add pg_dump for single databases you move or keep long. Alert on failed backups and archiving, and restore on a schedule to prove they work.

The service adds a search engine for listings, and the analytics team wants every booking in its warehouse within a minute. Both need each change as a row, not as changed pages of a file.

Logical decoding turns the WAL into a stream of row changes: inserts, updates, and deletes, grouped by transaction, in commit order. It needs wal_level = logical. On top of it, built-in logical replicationLogical replicationReplication that sends changed rows instead of statements or pages. Replicas can run different compatible versions, which allows an upgrade with a short switch, and other systems can read the same log for CDC.Read more: Replication: Choosing what the log carries copies chosen tables: a publication on the source names them, and a subscription on the target copies their current rows, then applies every change after that. Unlike a standby, the target is an ordinary server: it takes its own writes, can have other indexes, and can run a newer major version. Writes on the target to the same rows that arrive from the source cause conflicts, so keep them apart (more on what the replication log carries).

It copies changes to table rows, including TRUNCATE, and little else. Schema changes are not copied, so apply them on both sides yourself; for added columns and tables, the target goes first. Sequence values and large objects are not copied either; move sequences forward before the target takes writes (PostgreSQL). To find the row an update or a delete refers to, a table needs a primary key or another unique index chosen as its REPLICA IDENTITY, or REPLICA IDENTITY FULL, which writes the whole old row to the WAL and makes the target search for it.

Each subscription uses a slot on the source, and a logical slot has the risks of any slot: a consumer that stops makes WAL pile up until the disk fills, and it also holds back the cleanup of the system catalogs. Before PostgreSQL 17, a logical slot on the primary was not carried over to a standby by PostgreSQL itself, so after a failover a consumer often had to start over with a full copy. PostgreSQL 17 can copy slots created for failover to a standby, in the background, with sync_replication_slots on the standby. On the primary, synchronized_standby_slots makes logical consumers wait until that standby has the same WAL, so they never get ahead of it. Check that the slots are ready on the standby before you promote it (PostgreSQL).

A logical slot that survives failover Left, before failover: the primary has the WAL up to 0/5200 and a logical slot for the search consumer; the standby has flushed the WAL up to 0/5100 and keeps a synced copy of the slot. The consumer gets changes only up to 0/5100, what the standby already has. Right, after failover: the old primary is gone, the standby is promoted with the slot, and the consumer goes on from 0/5100. Before failover Primary WAL to 0/5200 Standby WAL to 0/5100 slot search_sub slot search_sub WAL Search consumer UP TO 0/5100 After failover Old primary WAL to 0/5200 New primary WAL to 0/5100 slot search_sub Search consumer FROM 0/5100

Change data captureChange data captureCapturing the committed changes of a database, often from its log, and sending them to other systems. Derived stores follow the owner without dual writes from the app. Consumers must apply the changes of one record in order.Read more: Databases: Deciding who owns each fact (CDC) tools such as Debezium read the same stream through a slot and publish each change to a broker, from which the search engine and the warehouse take it. Write the booking and an event to an outboxOutboxA table where the app writes an event in the same transaction as the change it describes. The change and its event commit together or not at all. A separate process delivers the events later and may retry.Read more: Databases: Deciding who owns each fact table in one transaction, and CDC publishes only events that committed; consumers still have to handle an event delivered twice. The same tools move tables to another database with little downtime.

From the WAL to row changes and their consumers Logical decoding reads the WAL through a slot and turns it into row changes grouped by transaction: insert booking 51077, update home 1042, commit. A subscription on another PostgreSQL server applies them, and Debezium publishes them to Kafka, from which the search engine and the warehouse read. WAL Decoding through a slot ROW CHANGES INSERT booking 51077 UPDATE home 1042 COMMIT IN COMMIT ORDER Subscriber another PostgreSQL Debezium to Kafka Search engine and warehouse

Use logical replication to copy tables to another server or version, and CDC through a slot to feed other systems with changes. Give every published table a primary key, plan schema changes on both sides, and alert on the WAL each slot holds.

The service runs PostgreSQL 14, which reaches its end of support in November 2026. Moving to 18 brings new features, such as asynchronous I/O and uuidv7(), but a major version can change the data files, so a standby cannot simply follow a primary on a newer version.

PostgreSQL releases a major version every year and supports each for five years. Minor releases come at least every three months, contain only fixes, and keep the file format, so you install the new binaries and restart, standbys first and then the primary after a switchover (PostgreSQL). Skipping minor releases leaves known bugs, including data-corrupting and security ones, in place; apply urgent fixes without waiting for the next quarter.

A major upgrade takes one of three paths:

  • Dump and restore: the slowest, hours for a large database, with writes stopped for the whole time.
  • pg_upgrade builds a new system catalog and reuses the data files. With --link, it does not copy the data, so it often takes minutes even for large databases; the time grows with the number of tables and objects, so measure it on a copy. Once the new server starts, the old one cannot be started again. Writes stop for that time, and the standbys are upgraded with rsync as the documentation describes, or rebuilt. Before PostgreSQL 18, it did not keep the planner’s statistics, so queries ran with poor plans until ANALYZE finished; since 18 it keeps most of them; run the commands it prints after the upgrade to rebuild the rest (PostgreSQL).
  • Logical replication: a new server on the new version subscribes to the old one and catches up while the service runs. At the switch, stop every writer: pause the pooler (PgBouncer’s PAUSE), and also the jobs and clients that connect around it. Wait until the new server has applied everything, move sequence values forward, point the pooler at it, and resume. Writes usually wait for seconds; measure it in a rehearsal. The new server needs a full copy of the data and time to catch up, and schema changes must be made on both servers or wait until the switch. When the old server already runs PostgreSQL 17 or later, pg_createsubscriber turns a standby of the same version into a logical subscriber, which saves the initial copy; that subscriber is then upgraded with pg_upgrade as a separate step.
How long writes stop on each upgrade path Dump and restore: writes stop for hours. pg_upgrade with --link: writes stop for minutes. Logical replication: the new server copies the data and catches up while the service runs, and writes stop only for seconds at the switch. WRITES STOPPED Dump and restore HOURS pg_upgrade --link MINUTES Logical replication copy, catch up: service runs SECONDS
Switching to the new server with logical replication The pooler pauses, and jobs that bypass it stop, so no writer reaches the old server. The new server applies the last changes from the old one. Sequences are moved forward on the new server, the pooler is pointed at it, and it resumes. Writes wait from the pause to the resume, usually seconds. POOLER OLD, 14 NEW, 18 PAUSE LAST CHANGES applies the rest move sequences POINT TO NEW RESUME WRITES WAIT

A new major version can change behavior, not only speed: PostgreSQL 12, for example, began inlining WITH queries into the main query, which changed their plans. Extensions need versions built for the new release too. Moving to a new operating system can change how text sorts, which breaks indexes on text: check the collation versions and rebuild the affected indexes. Run the test suite against the new version, compare the plans of the top queries from pg_stat_statements, and keep a way back until the new version has run under real load. Uber stayed on 9.2 in 2016 partly because a major upgrade of its replicated setup meant hours of downtime (Uber, 2016).

Apply minor releases soon after they come out. Plan a major upgrade every year or two, before support ends: pg_upgrade --link when the measured pause without writes is acceptable, logical replication when it must be shorter. Test plans and extensions on the new version first.

After a few years, the table of search events holds 5 billion rows and 2 TB, and a nightly job deletes events older than 90 days. The delete runs for hours, leaves millions of dead rows, and vacuuming the table takes longer than a day.

Table partitioning splits one table into several on the same server, by ranges of a column (RANGE, usually time), by lists of values (LIST, such as country), or by a hash (HASH). Queries and inserts go to the parent table, and PostgreSQL sends each row to its partition:

CREATE TABLE search_events (
id bigint GENERATED ALWAYS AS IDENTITY,
created_at timestamptz NOT NULL,
query text,
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
CREATE TABLE search_events_2026_05
PARTITION OF search_events
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
A query that reads one partition The table search_events is split into monthly partitions for February to May 2026. A query with created_at from 1 May reads only the May partition and skips the other three. WHERE created_at >= '2026-05-01' search_events parent table …_2026_02 SKIPPED …_2026_03 SKIPPED …_2026_04 SKIPPED …_2026_05 READ

Two things get cheaper. A query that filters by the partitioning column reads only the partitions that can match, and skips the rest. On 150 million payments, a query without a fitting index went from 62 s to 2.5 s after the table was split by month, because it read 348 MB instead of 9 GB (Angelakos). And old data leaves by dropping a whole partition: deleting 1.2 million old rows took 65.9 s, dropping their partition 43 ms, with no dead rows left behind (Angelakos). Each partition also has its own indexes and is vacuumed on its own.

Removing an old month with DELETE and with DROP The same four months of rows in both panels. Left, one table: DELETE marks the February rows as dead, and vacuuming must clean them later; deleting 1.2 million rows took 65.9 s. Right, a table partitioned by month: dropping the February partition removes its file at once; it took 43 ms. DELETE from one table FEB MAR APR MAY DEAD ROWS 65.9 s, dead rows left DROP the old partition FEB MAR APR MAY REMOVED 43 ms, nothing left

The price shows up everywhere else:

  • A primary key or a unique constraint must include the partitioning column, which is why the key above is (id, created_at). PostgreSQL has no index across all partitions, so it cannot keep an id unique by itself, and a foreign key that refers to the table must use the whole key.
  • A query without a condition on the partitioning column may have to read every partition, and planning time grows with the number of partitions. Measure planning time at the number of partitions you plan; hundreds are common, thousands need care.
  • An insert for which no partition exists fails. Create partitions ahead of time with pg_partman and pg_cron, and alert when the next one is missing.
  • An existing table does not turn into a partitioned one in place. Create a new partitioned table, copy the rows in batches while a trigger or logical replication carries over the changes made during the copy, and switch the app to it; views and foreign keys that refer to the old table need moving too.
  • Dropping a partition locks the parent table for a moment. DETACH PARTITION ... CONCURRENTLY (PostgreSQL 14) takes a weaker lock, then drop the detached table. CREATE INDEX CONCURRENTLY does not work on the parent: build the index on each partition and attach it. Autovacuum does not analyze the parent, so run ANALYZE on it yourself.

Partition a table when you have a concrete problem that partitioning solves: deleting old data, vacuum time, or queries that always filter by time. A table larger than memory, or larger than about 100 GB, is a sign to look for such problems, not a reason by itself (Atkinson). All partitions still live on one server and share its CPU, disks, and WAL. Partitioning makes a table manageable; it does not add capacity, as splitting the data across servers does (more on splitting a table across nodes).

Partition large tables that grow by time and lose old data by time, by a range of the time column, and drop partitions instead of deleting rows. Create future partitions automatically, and do not expect partitioning to raise the limits of the server.

Search, bookings, messages, and payments all run on one primary, and at peak it runs out of CPU and disk throughput. Every write in PostgreSQL goes through this one server, so its write load is the limit that standbys do not raise. Teams that met this limit picked from the same set of steps, the cheaper ones usually first, by what their bottleneck was:

  1. Remove waste and buy a bigger machine. Fix the top queries, drop unused indexes, put a pooler in front, and move to the largest instance. Figma moved its database to the largest instance AWS offered before it split anything (Figma, 2023).
  2. Move reads to standbys and caches. OpenAI serves millions of queries per second for ChatGPT from one primary and nearly 50 standbys (OpenAI, 2026).
  3. Write less. OpenAI cut writes in the app, let some writes happen later, and stopped adding tables to PostgreSQL: new workloads with heavy writes go to a sharded database (OpenAI, 2026).
  4. Move groups of tables to their own databases. Figma moved groups of related tables, picked by their share of the load and by how few joins tied them to the rest, to separate PostgreSQL servers. Joins and transactions across the groups moved into the app (Figma, 2023).
  5. Shard by key. Notion split its data by workspace ID into 480 logical shardsLogical shardOne of a fixed number of shards, many more than nodes, that a separate map assigns to nodes. Keys map to shards by a hash that never changes. Adding a node moves whole shards and changes only the map.Read more: Sharding: Adding nodes without moving every key on 32 servers (Notion, 2021), and two years later moved them to 96 servers with logical replication and no downtime (Notion, 2023). Figma built sharding into its own proxy in front of PostgreSQL after weighing distributed databases; sharding its first table took about nine months, and the rest of the work went on longer (Figma, 2024).

Sharding by hand makes the app send each query to the right shard, and gives up joins, constraints, and transactions across shards (more on sharding). The Citus extension does the routing inside PostgreSQL: a coordinator splits tables by a distribution column across worker servers, and tables split by the same column, such as everything keyed by host, join on each worker. Every shard, whatever does the routing, adds its own standbys, backups, vacuuming, upgrades, and connection budget.

Citus splits tables by host across workers The app sends queries to the Citus coordinator, which routes them by host_id. Three workers each hold one range of hosts, with both the homes and the bookings of those hosts, so a join of homes and bookings of one host runs on one worker. App Coordinator routes by host_id WORKER 1: HOSTS 1–100 homes bookings JOIN WORKER 2: HOSTS 101–200 homes bookings JOIN WORKER 3: HOSTS 201–300 homes bookings JOIN

Shards can live in different regions, each taking writes for its own users. Leave PostgreSQL for this part of the data when the same rows need writes accepted locally in several regions, with availability or coordination that one primary per shard cannot give, or when the write rate needs automatic resharding that you do not want to build. Distributed SQL databases such as CockroachDB and YugabyteDB speak the PostgreSQL protocol, but they are different systems: extensions, isolation, and performance differ, so test what the app relies on. For very high write rates of simple records read by key, a wide-column storeWide-column modelA data model that groups rows into partitions by key and sorts the rows inside each partition. A query reads a range of rows from one partition. Stores of this kind spread partitions across many machines and suit heavy writes.Read more: Databases: Keeping related rows together in order such as Cassandra fits.

Start on one PostgreSQL primary with standbys, and say what would make you change that: the write load of the primary, the size of the largest tables, or the same data needing local writes in several regions. Then name the steps and what each one fixes: tune and scale upVertical scalingHandling more load by moving to a bigger machine. The code does not change, but the largest machine is a hard ceiling, and maintenance on one huge database gets slow.Read more: Sharding: Outgrowing one node, move reads to standbys and caches, move independent groups of tables to their own servers, and shard a dominant data set by a key such as the host or the tenant.

Push one primary as far as tuning, a larger machine, standbys, and caches take it. Move independent groups of tables out when they share little with the rest, shard by key when one data set is the limit, and use many logical shards on few servers so that you can move them later.

  • One instance is a postmaster with a process per connection, shared buffers on top of the OS page cache, and background processes for writing pages, vacuuming, and sending WAL to standbys. All writes go to one primary; start shared buffers at about a quarter of RAM on a dedicated machine.
  • A commit waits for the WAL flush, and one flush serves many commits. Checkpoints bound recovery; full-page copies protect from torn pages. synchronous_commit = off can lose the last commits, up to about 0.6 s with default settings, without breaking the data; never turn off fsync or full_page_writes. Keep data checksums on.
  • Put rules for all writers into the table definitions where PostgreSQL can express them: types, NOT NULL, CHECK, unique and foreign keys, and exclusion constraints on ranges against overlaps. Use timestamptz, numeric, text with CHECK, and bigint identity; expect gaps in sequences; index foreign keys you join or delete on; uuidv7() (PostgreSQL 18) keeps new keys mostly at the end of the index.
  • JSONB holds varied attributes next to typed columns. A GIN index serves containment, key tests, and JSONPath; jsonb_path_ops is smaller and has no key tests; a B-tree on one expression is far smaller. The planner has no statistics inside documents unless you add them, and keys inside are checked only by CHECK.
  • A table is a heap of 8 KB pages; B-tree indexes point to a row’s TID, so an update that is not HOT needs new entries in the applicable ones. Long values in rows over about 2 KB are compressed or moved to TOAST; select only the columns you need.
  • Pick an index by the operator: B-tree for equality, ranges, and order; GIN for “contains”; GiST for overlaps and distance; BRIN for huge tables in time order. An index on (a, b) serves a, a with b, and a ordered by b; b alone reads most of it, unless skip scan helps. Partial, expression, and covering indexes fit narrower queries. An index that fetches rows from the table pays off when the queried value matches a small share of them; every index slows writes.
  • Rank queries by total time in pg_stat_statements, check for lock waits, read EXPLAIN (ANALYZE, BUFFERS), and fix the earliest real cause: a wrong estimate, a function over a column, a generic plan, a spill, OFFSET, or N+1 queries.
  • Each connection is a process, and past some number of active connections, more only add latency. Use a pooler such as PgBouncer in transaction mode, size pools from the server side, and set timeouts per role. Session state such as SET, LISTEN, and session advisory locks breaks behind it.
  • An update writes a new row version and new entries in every applicable index that points to rows, unless it is a HOT update: no column used by those indexes changed and the page had room. A BRIN index does not prevent HOT (PostgreSQL 16). Keep hot columns out of indexes and try a lower fillfactor on tables with many updates.
  • Vacuuming removes old versions but rarely shrinks files. Tune autovacuum per large table, and keep snapshots and writing transactions short, because the oldest one keeps newer dead rows in every table. Fix what holds cleanup back first, and rebuild online only when the remaining bloat is worth the locks, disk, and I/O.
  • 32-bit transaction IDs wrap around; vacuuming freezes old rows. With 3 million IDs left, the database refuses new writing transactions. Alert on age(datfrozenxid) well before 200 million and keep vacuuming fast.
  • Read committed is the default and re-reads a locked row before an update; repeatable read is snapshot isolation; serializable is SSI with retries. Row locks live in the rows; advisory locks are app conventions. Avoid many savepoints in one transaction.
  • Most ALTER TABLE commands queue behind long transactions and block everything behind them: set lock_timeout and retry. Avoid table rewrites, add check and foreign key constraints NOT VALID, build indexes CONCURRENTLY, and backfill in batches.
  • A job table with FOR UPDATE SKIP LOCKED, leases, deletes that check the claim, and handlers safe to repeat replaces a broker while jobs commit with the business data. NOTIFY is only a wake-up for connected listeners, and the commits that notify run one at a time. Move to a broker for fan-out, replay, or heavy traffic.
  • Full-text search: a stored tsvector, GIN, and websearch_to_tsquery; filter before ranking, pg_trgm for typos and substrings. Move to a search engine when relevance, per-language analysis, or facets cost too much to build in PostgreSQL.
  • PostGIS: geography points and a GiST index; ST_DWithin, <->, and ST_Covers use it, ST_Distance in a filter does not. Measure the write load of fast-moving positions.
  • pgvector: exact search for small or narrowly filtered sets, HNSW when that is too slow, recall measured against exact search, iterative scans for filtered queries. After trying smaller vectors and index settings, move to a vector database when the index does not fit, needs several machines, or misses the recall you need.
  • Unlogged tables serve caches of slow calls but do not relieve the database; materialized views serve totals that may be minutes old; hot counters become appended rows; history uses BRIN and time partitions.
  • Streaming replication copies the whole instance to standbys of the same major version. synchronous_commit per transaction chooses what a commit waits for; wait for a quorum such as ANY 1 of two. Read your own writes from the primary or from a standby that has replayed your WAL position. Standby queries conflict with replay; slots keep WAL and need max_slot_wal_keep_size.
  • PostgreSQL has no built-in failover: use a managed service with high availability turned on, or Patroni with etcd and a watchdog, or a Kubernetes operator, and route clients by role. Rejoin an old primary with pg_rewind.
  • A standby copies mistakes. Back up with a physical backup and a WAL archive for point-in-time recovery, add pg_dump for single databases, alert on failures, and restore on a schedule.
  • Logical replication copies chosen tables to a writable server, also on a newer version, without schema changes and sequence values. CDC tools read the same stream through a slot; failover slots need PostgreSQL 17 and setup.
  • Apply minor releases soon. Upgrade major versions with pg_upgrade --link (a pause of minutes, measured) or logical replication (a shorter pause, with every writer stopped), after testing plans, extensions, and collations.
  • Partition large tables that grow and expire by time when that solves a concrete problem, and detach or drop partitions instead of deleting rows. Unique keys must include the partitioning column; partitioning adds no server capacity.
  • When one primary is not enough, pick steps by the bottleneck: tune and scale up, move reads to standbys and caches, write less, move independent groups of tables to their own servers, or shard a dominant data set by key with many logical shards or Citus. Data owned by one region can stay on that region’s primary; consider leaving PostgreSQL when the same data needs local writes in several regions that one primary cannot serve.

© 2026 Grig Alex · CC BY-SA 4.0