The idea: one process per connection

PostgreSQL does not use threads. When it starts, one process, the postmaster, opens port 5432 and starts a few background processes. Every client connection then gets its own operating-system process, a backend, made with fork(). The processes never call each other. They cooperate through one block of shared memory that every one of them maps, and each backend keeps its own private memory for sorts and hashes.

The postmaster on port 5432 forks one backend per client (pid 201 for C1, 202 for C2) and the background processes: checkpointer, background writer, walwriter, autovacuum launcher with its workers, walsender. All of them map one shared memory block holding shared_buffers, WAL buffers, the lock table, CLOG and ProcArray; each backend also has private work_mem.
The postmaster only forks and supervises; backends and background processes share one memory segment and nothing else.

The canvas draws a toy server with max_connections = 4 (the default is 100), 4 of the 16384 shared buffers, one table users with two 8 KB pages, and five clients C1 to C5.

From connect to ReadyForQuery

  1. The client opens a TCP connection to port 5432. Only the postmaster listens there.
  2. The postmaster accept()s it and forks right away. The child inherits the socket; the postmaster goes back to listening. It never parses client data itself, so a bad packet cannot take down the supervisor.
  3. The child reads the StartupMessage (protocol version, user, database).
  4. InitProcess() takes a free PGPROC entry in shared memory. There are exactly max_connections of them for clients (plus separate ones for autovacuum workers and background processes). If none is free, the child fails here.
  5. The child checks pg_hba.conf and authenticates, usually with SCRAM-SHA-256: AuthenticationSASL, the client's proof, AuthenticationOk.
  6. It sends ReadyForQuery. From now on the client talks only to this backend, until it disconnects (Demo: a client connects).

A fork costs a few hundred microseconds to a few milliseconds, and the new backend then fills its catalog caches from scratch. That is cheap for a long session and expensive for a web request that connects, runs one query and leaves. Copy-on-write fork shows why the fork itself is not a full memory copy.

Shared and private memory

MemoryShared?What it holds
shared_buffersshared8 KB copies of table and index pages. A page read by one backend is a hit for all others (Demo: two backends share one buffer).
WAL bufferssharedWAL records not yet written to pg_wal.
lock tablesharedTable-level locks such as RowExclusiveLock. Row locks live in the tuples themselves.
CLOG (pg_xact)shared cacheTwo bits per transaction id: running, committed, aborted.
ProcArraysharedEvery process with its running xid; each snapshot reads it.
pgstatssharedCounters such as n_dead_tup that autovacuum reads.
work_memprivateMemory for one sort or hash in one backend. A query may use several at once.

An UPDATE shows how they work together (Demo: UPDATE, WAL and commit): the backend publishes an xid in the ProcArray, takes a lock in the lock table, changes the page in a shared buffer, logs a WAL record in the WAL buffers, and at commit flushes the WAL to pg_wal and marks the xid committed in the CLOG. The data page stays dirty in memory; see the buffer pool and WAL internals for what happens to it next.

Background processes

ProcessJob
checkpointerEvery checkpoint_timeout (5 min) or max_wal_size, writes all dirty buffers and logs a checkpoint, the new start point for recovery.
background writerWrites some dirty buffers ahead of time, so backends rarely have to write one to free a buffer.
walwriterFlushes WAL buffers every wal_writer_delay (200 ms), for records no commit is waiting on.
autovacuum launcherWakes every autovacuum_naptime (1 min), checks n_dead_tup > 50 + 0.2 × reltuples, and asks the postmaster for a worker (Demo: autovacuum launcher starts a worker).
autovacuum workersUp to autovacuum_max_workers (3); each vacuums tables in one database, then exits.
walsenderOne per standby or logical replication connection; streams WAL. See logical decoding.
startupOnly during crash recovery or on a standby: replays WAL.

Only the postmaster forks. The launcher sets a flag in shared memory and signals the postmaster, which starts the worker. ps shows them all, e.g. postgres: autovacuum worker shop or postgres: app shop 10.0.0.5(51234) idle.

When a backend crashes

If a backend dies from a signal (a segfault, kill -9, the Linux OOM killer), it may have died while holding a spinlock or halfway through changing a shared structure. The postmaster cannot know, so it treats all of shared memory as corrupt:

  1. It logs terminating any other active server processes and sends SIGQUIT to every child. Healthy sessions get WARNING: terminating connection because of crash of another server process and the connection is closed.
  2. It deletes and re-creates shared memory. Dirty buffers are lost.
  3. It starts the startup process, which replays WAL from the last checkpoint's redo point. A record is applied only if the page's LSN is older than the record.
  4. The checkpointer writes an end-of-recovery checkpoint, the other background processes start again, and the server logs database system is ready to accept connections.
Time runs down. Backend pid 202 dies with SIGSEGV; the postmaster gets SIGCHLD and sends SIGQUIT to pid 201 and the background processes; shared memory is re-created and the dirty page users/1 is lost; the startup process replays WAL 0/C8 UPDATE and 0/F0 COMMIT from pg_wal; the checkpointer writes users/1 and a CHECKPOINT; the server is ready and clients reconnect.
One crashed backend restarts the whole server; the committed UPDATE survives because its WAL was flushed before the client heard "UPDATE 20".

So one crashing session costs every session its connection, but no committed data (Demo: a backend crashes, crash recovery). restart_after_crash = off makes the postmaster exit instead, for clusters managed by an external failover tool. A plain ERROR or even a FATAL in one session is not a crash: that backend exits cleanly and nobody else notices. Compare ARIES-style recovery, which also has an undo pass; PostgreSQL needs none because aborted rows stay in the heap, invisible.

max_connections and poolers

max_connections is fixed at start, because it sizes the PGPROC array and the lock table in shared memory. When all slots are taken, the next child fails with FATAL: sorry, too many clients already (Demo: too many clients, use a pooler). Three slots are also held back for superusers (superuser_reserved_connections), which the page leaves out.

Raising the limit is not free. Every backend is a process with its own catalog cache and work_mem; every snapshot scans the ProcArray, so a thousand mostly idle connections slow everyone down. The usual answer is a connection pooler such as PgBouncer in transaction mode: thousands of client connections share, say, 20 server connections, and a backend is lent to a client only for one transaction. See Database Connection Pool for how a pool hands out connections, and pool sizing for how many to keep.

What the page leaves out

Superuser-reserved slots, the logger and archiver processes, parallel query workers (also forked by the postmaster; see parallel query), dynamic shared memory, the SLRU caches other than CLOG, the checkpointer's own timing, buffer eviction with the clock sweep, SSL negotiation before the startup packet, query cancellation (a separate connection carrying a secret key), and Windows, where PostgreSQL starts a new process instead of forking. Autovacuum also freezes old tuples and updates the visibility map; see XID wraparound and the free space and visibility maps.