PostgreSQL architecture
Article.
“Why PostgreSQL?” is easy to answer with a feature list. The senior answer explains its operational behaviour from three design choices: a process per connection, old row versions kept in the table itself, and a write-ahead log as the single source of durability. Connection limits, bloat, VACUUM, replication and most outages follow from those three.
Why does a PostgreSQL table grow when you only update rows, and stop shrinking after you delete them? The answer is in how MVCC stores rows. Outputs below are from PostgreSQL 18.4 in Docker, default settings.
Processes: one backend per connection
postmaster (pid 1) accepts, forks backends
├── client backend one process per connection
├── checkpointer dirty pages at checkpoints
├── background writer dirty pages in between
├── walwriter flushes WAL buffers
├── autovacuum launcher starts vacuum workers
├── io worker 0..2 async reads (new in 18)
└── logical replication launcherThat is the real process list of an idle 18.4 server. Every client connection forks a new backend process with its own memory and catalogue caches. Consequences:
- Connections are expensive. pgbench, read-only queries, four clients: 27,122 transactions a second on persistent connections, 388 when each transaction opened a new one (2.5 ms per connection).
- Connections are limited.
max_connectionsdefaults to 100. Each idle backend still costs memory and makes snapshots slower to take, so thousands of direct connections hurt even when idle. - So every production deployment pools: a pool in the application (SQLAlchemy, psycopg_pool) and often PgBouncer in transaction mode in front (Connection pooling).
Memory
| Setting | Scope | Default |
|---|---|---|
shared_buffers |
one cache shared by all backends | 128 MB |
work_mem |
per sort or hash node, per backend | 4 MB |
maintenance_work_mem |
per VACUUM, CREATE INDEX | 64 MB |
shared_bufferssits on top of the OS page cache, so data is often cached twice. The usual starting point is about 25% of RAM, leaving the rest to the OS cache, which PostgreSQL relies on deliberately.work_memis not a per-server limit. A query with three hash joins and a sort may use four times it (hashes up tohash_mem_multiplier, 2.0, times more), in every backend running such a query. 200 connections × 4 nodes × 64 MB is 50 GB: raising it globally is how servers get OOM-killed. Raise it per session or per role for the reports that need it.- When a sort or hash exceeds it, it spills to disk;
EXPLAIN ANALYZEshowsSort Method: external merge Disk: ...(Reading a query plan).
Storage and MVCC
Tables are heaps: unordered 8 kB pages of row versions (tuples). Indexes
point at a tuple’s physical address, its ctid (page, slot). Unlike MySQL’s
InnoDB, the table is not organised by the primary key; the primary key is
an ordinary index.
An UPDATE writes a new row
SELECT ctid, xmin, xmax, * FROM acct;
-- (0,1) | 783 | 0 | id=1 bal=100
UPDATE acct SET bal = bal - 10 WHERE id = 1;
SELECT ctid, xmin, xmax, * FROM acct;
-- (0,3) | 784 | 0 | id=1 bal=90 new version, new placeThe old version at (0,1) is still on the page, now with xmax = 784: the
transaction that ended it. xmin is the transaction that created a version.
Each transaction’s snapshot decides which versions it sees, which is how
readers never block writers and writers never block readers
(Transaction isolation levels).
The price: every UPDATE and DELETE leaves a dead tuple behind, and something must reclaim it.
HOT updates
If no indexed column changes and the page has room, the new version goes on
the same page and indexes are not touched: a heap-only tuple (HOT)
update. Updating an indexed column, or a full page, means a new entry in
every index on the table. That is why indexing a frequently updated column
such as updated_at costs more than the index itself, and why hot tables
are sometimes created with fillfactor = 90 to leave room on each page.
TOAST
Values over about 2 kB are compressed and moved out of line into a TOAST
table. A SELECT * that touches a large jsonb or text column pays to
fetch and decompress it even if the application ignores it.
VACUUM and the xmin horizon
VACUUM removes dead tuples that no running transaction can still see, marks their space reusable, and updates the visibility map that index-only scans depend on. Autovacuum runs it when a table’s dead tuples pass a threshold (VACUUM, autovacuum, table bloat, and transaction wraparound).
The catch is the xmin horizon: a dead tuple can only be removed when it is older than the oldest snapshot still open. One long transaction holds back cleanup for the whole database:
-- a REPEATABLE READ transaction open, 100,000 rows updated, then VACUUM
tuples: 0 removed, 200000 remain, 100000 are dead but not yet removable
-- after that transaction committed
tuples: 0 removed, 100000 remain, 0 are dead but not yet removable
table size: 7080 kB before and afterTwo facts from that output. Cleanup waited for the transaction, not for VACUUM.
And the table did not shrink: VACUUM makes space reusable inside the
file, it does not return it to the OS. Only VACUUM FULL or pg_repack
rewrites the table smaller, and VACUUM FULL locks it exclusively while it
does.
Things that hold the horizon back: a long transaction, an idle in transaction session, an abandoned replication slot, a long query on a
replica with hot_standby_feedback on, or a forgotten prepared transaction.
WAL: durability and replication
Every change is written to the write-ahead log before the data page changes, and a commit returns once its WAL record is flushed. Data pages are written later by the background writer and the checkpointer; after a crash, recovery replays WAL from the last checkpoint (WAL, Checkpoints, and fsync).
The same log is the replication stream. A physical replica replays WAL byte for byte, giving an identical read-only copy. Logical replication decodes WAL into row changes, which can feed another PostgreSQL version, a subset of tables, or CDC tools such as Debezium (Replication and Logical Decoding).
synchronous_commit chooses how much of this a commit waits for, from not
flushing locally to applied on a synchronous replica.
What changed in PostgreSQL 18
| Change | Why it matters |
|---|---|
| Asynchronous I/O | io_method = worker by default, io_uring on Linux |
uuidv7() |
time-ordered UUID primary keys, index friendly |
| Virtual generated columns | the default; computed on read |
| B-tree skip scan | multicolumn index usable without the first column |
RETURNING OLD/NEW |
before and after values in one statement |
| Checksums on | initdb enables data checksums by default |
| OAuth authentication | oauth method in pg_hba.conf |
| Upgrade keeps stats | pg_upgrade carries planner statistics over |
The last one matters operationally: before 18, a major-version upgrade
started with empty statistics and bad plans until ANALYZE finished.
PostgreSQL vs MySQL (InnoDB), in brief
| PostgreSQL | MySQL InnoDB | |
|---|---|---|
| Concurrency | process per connection | thread per connection |
| Table layout | heap; PK is a separate index | clustered on the PK |
| Old row versions | in the table; VACUUM | undo log; purge thread |
| Default isolation | Read Committed | Repeatable Read |
| Strengths | types, extensions, planner | simple ops, replication |
Neither layout is better in general. The clustered index makes primary-key range scans cheap and secondary-index lookups a second B-tree descent; PostgreSQL’s heap makes every index equal and pays in VACUUM instead (MySQL).
Where it breaks in production
Connection storms
A deploy restarts 50 pods, each opening a pool of 20: 1,000 connections
against max_connections = 100, FATAL: sorry, too many clients already,
and the new pods fail health checks. Size pools from the database’s limit
downward, and put PgBouncer in front when the number of clients is not under
your control.
idle in transaction
An application that opens a transaction, then calls an external API before
committing, leaves sessions idle in transaction: they hold locks and the
xmin horizon. Bloat grows on every hot table, queries slow down, and nothing
in the application logs explains why. Set
idle_in_transaction_session_timeout, and keep network calls out of
transactions. To find who holds the horizon:
SELECT pid, state, now() - xact_start AS xact_age,
backend_xmin, left(query, 40) AS query
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL
ORDER BY xact_start
LIMIT 5;The oldest xact_start at the top is the session to look at; check
pg_replication_slots and pg_prepared_xacts as well, which this view does
not show.
A replication slot nobody reads
A logical slot whose consumer died keeps every WAL segment from that point,
and the disk fills with pg_wal. Since 13, max_slot_wal_keep_size caps it;
monitor pg_replication_slots for inactive slots.
Transaction ID wraparound
Transaction IDs are 32-bit. If VACUUM cannot freeze old tuples, because it
is blocked by the horizon or too slow on a huge table, the database stops
accepting writes to protect itself. It is rare, it is a full outage, and
the warnings appear in the log long before; alert on
age(datfrozenxid).
work_mem raised globally
A slow report is fixed by setting work_mem = 256MB in the config. Weeks
later, a traffic spike runs many such queries at once and the OOM killer
takes the postmaster down. Set it per role or per query.
The trade not taken
PostgreSQL chose processes over threads, and in-table versions over an undo log. Processes isolate crashes and make the code simpler, at the cost of expensive connections, which the ecosystem answers with poolers. Keeping old versions in the heap makes rollback instant and readers never wait, at the cost of VACUUM and bloat.
An undo log, as in InnoDB and Oracle, keeps the table compact but makes long-running readers reconstruct old rows and can fail them with “snapshot too old”. Both trades show up as the operational work a PostgreSQL team does: pooling and vacuum tuning.
Related
Interview angle 5
- “How does PostgreSQL handle a connection?” - the postmaster forks one
backend process per connection. That makes connections expensive: 27,000
transactions a second on persistent connections against 388 with a new one
each time. Hence pooling and PgBouncer.
- Follow-up: “Why not just raise
max_connections?” - each backend costs memory,work_memmultiplies by connections, and snapshots get slower with many backends. Pool instead.
- Follow-up: “Why not just raise
- “What happens on an UPDATE?” - a new row version is written, the old
one is marked with the updating transaction’s ID in
xmax, and it stays until VACUUM removes it. Readers see whichever version their snapshot allows, so readers and writers do not block each other.- Follow-up: “What is a HOT update?” - no indexed column changed and the page had room, so the new version stays on the same page and no index is updated.
- “Why is the table growing?” - dead tuples VACUUM cannot remove,
usually because a long or idle-in-transaction session holds the xmin
horizon. With one open, 100,000 dead rows were “not yet removable”.
- Follow-up: “Why didn’t it shrink after VACUUM?” - VACUUM makes space
reusable but does not give it back;
pg_repackorVACUUM FULLdoes.
- Follow-up: “Why didn’t it shrink after VACUUM?” - VACUUM makes space
reusable but does not give it back;
- “How is a commit durable?” - its WAL record is flushed before the commit returns; data pages are written later, and recovery replays WAL.
- “PostgreSQL or MySQL?” - heap and VACUUM vs clustered index and undo log; processes vs threads; PostgreSQL for types, extensions and a strong planner. For most new backends, PostgreSQL is the default choice.