PostgreSQL in Depth Module 1 – How PostgreSQL works and how to run it
PostgreSQL architecture: processes, memory, files
How a PostgreSQL 18 server is built: the postmaster and its processes, shared buffers against per-query memory, and the files that hold every table.
What you will learn
- Describe what the postmaster, the client backends and each background process of a PostgreSQL 18 server do
- Explain how shared buffers, WAL buffers and per-operation memory such as work_mem differ
- Find a table's files in the data directory and name each of its forks
- Identify the processes of a running server, a primary or a standby, from pg_stat_activity
What you’ll learn
What you will learn
- Describe what the postmaster, the client backends and each background process of a PostgreSQL 18 server do
- Explain how shared buffers, WAL buffers and per-operation memory such as work_mem differ
- Find a table's files in the data directory and name each of its forks
- Identify the processes of a running server, a primary or a standby, from pg_stat_activity
On this page
With three psql sessions open, the PostgreSQL 18 server of this lesson shows 12 processes in its activity view, and a 13th, the postmaster, started all of them: one client backend for each connection and nine background processes. They share one block of memory, most of it a 128 MB cache of table pages, each backend takes more for its own sorts, and every table is a set of files in one data directory. Knowing which part does what turns a stuck connection, gigabytes of temporary files or an out-of-memory crash into something you can find.
You will follow one connection from its first packet to its last, name every background process of a primary and a standby, size the shared memory and the memory each query takes for itself, and find the files of a table on disk, fork by fork.
Every connection gets a process of its own
PostgreSQL’s manual calls its design a “process per user” client/server model. One process, the postmaster: The supervisor process of a PostgreSQL server. It starts the background processes, listens for connections and starts a client backend for each one; it runs no queries itself., listens for connections. For each one it starts a new process, a client backend: The server process that PostgreSQL starts for one client connection. It authenticates the client and runs every query of that session, and it exits when the client disconnects., and from then on the client talks only to that backend, while the postmaster goes back to listening. Step through one psql session on the server of this lesson:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- The client knocks. psql opens a connection to the server's port on 127.0.0.1, 5432 unless a server sets another; the lesson's server takes an unused one. The only process listening there is the postmaster, the supervisor that started every other process of the server.
- A process for this client. The postmaster starts a new process, a client backend, and hands it the connection. From now on psql talks only to that backend, and the postmaster goes back to waiting for the next client.
- Who are you?. The backend reads pg_hba.conf to choose how this client must prove who it is. On the lesson's server that is a scram-sha-256 password exchange, so the password itself never crosses the connection.
- Ready for queries. psql answers the challenge correctly. The backend loads the settings for this role and database and reports that it is ready for a query.
- A query arrives. psql sends a SELECT on pg_stat_activity. The backend parses and plans it, then runs it, reading the pages it needs through the shared buffers that every process of the server shares.
- Rows go back. The backend sends the result rows to psql. Its sorting and hashing memory, work_mem, belongs to this backend alone and is given back when the operation ends.
- The client leaves. psql disconnects. The backend process exits and its private memory goes with it, while the postmaster keeps running and listening, ready to start a backend for the next client.
The run below starts a server, opens two psql connections that sit idle, and asks the server’s activity view,
pg_stat_activity, which kinds of process it runs. It then attaches a standby and asks the same question on both
servers, which the next section reads:
processes_session.py
"""The processes of a PostgreSQL 18.6 server: alone with three connections, then with a streaming standby."""
from pg_session import Server
with Server(settings={"summarize_wal": "on"}) as pg:
pg.up()
with pg.sessions(2):
pg.psql("processes.sql", note="the primary, with two more psql connections open")
standby_port = pg.standby()
pg.psql("processes.sql", note="the primary again, now with a streaming standby attached")
pg.psql("processes.sql", port=standby_port, note="the standby") Output
-- the primary, with two more psql connections open
$ psql -X -a -f processes.sql
-- Which kinds of process does this server run, and how many of each?
SELECT current_setting('server_version') AS version, pg_is_in_recovery() AS standby;
version | standby
---------+---------
18.6 | f
(1 row)
SELECT backend_type, count(*) FROM pg_stat_activity GROUP BY backend_type ORDER BY backend_type;
backend_type | count
------------------------------+-------
autovacuum launcher | 1
background writer | 1
checkpointer | 1
client backend | 3
io worker | 3
logical replication launcher | 1
walsummarizer | 1
walwriter | 1
(8 rows)
-- the primary again, now with a streaming standby attached
$ psql -X -a -f processes.sql
-- Which kinds of process does this server run, and how many of each?
SELECT current_setting('server_version') AS version, pg_is_in_recovery() AS standby;
version | standby
---------+---------
18.6 | f
(1 row)
SELECT backend_type, count(*) FROM pg_stat_activity GROUP BY backend_type ORDER BY backend_type;
backend_type | count
------------------------------+-------
autovacuum launcher | 1
background writer | 1
checkpointer | 1
client backend | 1
io worker | 3
logical replication launcher | 1
walsender | 1
walsummarizer | 1
walwriter | 1
(9 rows)
-- the standby
$ psql -X -a -f processes.sql
-- Which kinds of process does this server run, and how many of each?
SELECT current_setting('server_version') AS version, pg_is_in_recovery() AS standby;
version | standby
---------+---------
18.6 | t
(1 row)
SELECT backend_type, count(*) FROM pg_stat_activity GROUP BY backend_type ORDER BY backend_type;
backend_type | count
-------------------+-------
background writer | 1
checkpointer | 1
client backend | 1
io worker | 3
startup | 1
walreceiver | 1
walsummarizer | 1
(7 rows)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 processes_session.py
processes.sql
-- Which kinds of process does this server run, and how many of each?
SELECT current_setting('server_version') AS version, pg_is_in_recovery() AS standby;
SELECT backend_type, count(*) FROM pg_stat_activity GROUP BY backend_type ORDER BY backend_type; pg_session.py
"""Throwaway PostgreSQL 18.6 servers for the examples of this lesson.
Every session creates a cluster of its own with initdb in a new folder inside the examples folder, starts it, runs
psql scripts and server programs on it, prints what they printed, and stops and deletes it again, also when something
fails. The server is the pinned PostgreSQL 18.6 from the mise installations next to the Python that runs this file.
It listens only on 127.0.0.1, an address no other computer can reach, on a port the operating system reports as
unused, with no Unix socket, and it asks for a password (scram-sha-256) made up for the run and never printed.
learn:verify's run sandbox cannot read the home folder, where mise keeps PostgreSQL, so these sessions are recorded
with LEARN_VERIFY_NETWORK=allow; they read only this folder and the toolchain and connect to nothing but 127.0.0.1.
The output is exactly what the programs printed, with four substitutions so that a record never shows the computer
it ran on and is the same on every run: the run's folder is shown as ".", the folder of PostgreSQL's programs is left
out of their paths, the operating-system user's name is shown as "learner", and the dots pg_ctl prints while it waits
(one about every tenth of a second) as three. The sessions never print the port. A session stops with status 1 when
a command fails that should have worked, or reports a different number of errors than the session expects, so a run
that went wrong is never recorded as if it had worked.
"""
import os
import pwd
import re
import secrets
import shlex
import shutil
import socket
import subprocess
import sys
import tempfile
import textwrap
import time
from pathlib import Path
PG_VERSION = "18.6"
# .../installs/python/3.14.8/bin/python3 -> .../installs
INSTALLS = Path(sys.executable).resolve().parents[3]
BIN = INSTALLS / "postgres" / PG_VERSION / "bin"
OS_USER = pwd.getpwuid(os.geteuid()).pw_name
def free_port():
"""A TCP port on 127.0.0.1 that nothing listens on now."""
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
return probe.getsockname()[1]
class Server:
"""One throwaway cluster: initdb, settings, start; stopped and deleted when the with block ends."""
def __init__(self, settings=None, initdb=(), name="data"):
self.settings = dict(settings or {})
self.initdb_args = list(initdb)
self.name = name
self.running = []
def __enter__(self):
if not (BIN / "postgres").exists():
sys.exit(f"PostgreSQL {PG_VERSION} was not found next to this Python")
self.base = Path(tempfile.mkdtemp(prefix="pg", dir=".")).resolve()
self.port = free_port()
self.password = secrets.token_urlsafe(24)
(self.base / "pwfile").write_text(self.password + "\n")
self.env = {**os.environ, "PGHOST": "127.0.0.1", "PGPORT": str(self.port), "PGUSER": "postgres",
"PGPASSWORD": self.password, "PGDATABASE": "postgres", "PGCONNECT_TIMEOUT": "10"}
for key in ("PGSERVICE", "PGSERVICEFILE", "PGOPTIONS", "PSQLRC"):
self.env.pop(key, None)
for script in Path(".").glob("*.sql"):
shutil.copy(script, self.base / script.name)
for data in Path(".").glob("*.csv"):
shutil.copy(data, self.base / data.name)
return self
def __exit__(self, *exc):
for folder, _port in reversed(self.running):
self._call(["pg_ctl", "-D", folder, "-m", "fast", "-w", "-t", "60", "stop"])
self.running = []
shutil.rmtree(self.base, ignore_errors=True)
# ---- the cluster ---------------------------------------------------------------------------------------------
def initdb(self, show=False):
args = ["initdb", "-D", self.name, "-U", "postgres", "--auth=scram-sha-256", "--pwfile=pwfile",
*self.initdb_args]
if not show:
args.append("--no-instructions")
self._expect(args, show=show)
with open(self.base / self.name / "postgresql.conf", "a") as conf:
conf.write("\n# Added by the session: 127.0.0.1 only, no Unix socket.\n")
conf.write(f"listen_addresses = '127.0.0.1'\nport = {self.port}\nunix_socket_directories = ''\n")
for key, value in self.settings.items():
conf.write(f"{key} = {value}\n")
def start(self, show=False, folder=None, port=None):
folder = folder or self.name
self._expect(["pg_ctl", "-D", folder, "-l", f"{folder}.log", "-w", "-t", "60", "start"], show=show)
self.running.append((folder, port or self.port))
def up(self):
"""initdb and start, quietly."""
self.initdb()
self.start()
return self
def standby(self, folder="standby"):
"""A streaming standby of this server, made with pg_basebackup -R, started on a port of its own."""
self._expect(["pg_basebackup", "-D", folder, "-R", "-X", "stream", "-c", "fast"])
port = free_port()
with open(self.base / folder / "postgresql.conf", "a") as conf:
conf.write(f"port = {port}\n")
self.start(folder=folder, port=port)
for _ in range(300):
if self.query("SELECT count(*) FROM pg_stat_replication WHERE state = 'streaming'") == "1":
return port
time.sleep(0.1)
sys.exit("the standby did not start streaming")
# ---- running things ------------------------------------------------------------------------------------------
def _call(self, args, env=None):
args = [str(a) for a in args]
done = subprocess.run([str(BIN / args[0]), *args[1:]], stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, cwd=self.base, env=env or self.env)
return done.returncode, self.clean(done.stdout)
def _expect(self, args, show=False, status=0):
code, out = self._call(args)
if show:
_show(args, out)
if code != status:
if not show:
print(out)
sys.exit(f"{args[0]} ended with status {code}, not {status}")
return out
def clean(self, text):
"""The run's folder as ".", the user as learner, pg_ctl's waiting dots as three (see the docstring)."""
text = text.replace(str(BIN) + "/", "").replace(str(self.base) + "/", "").replace(str(self.base), ".")
text = re.sub(r"(waiting for server to (?:start|shut down))\.+", r"\1...", text)
return text.replace(f'"{OS_USER}"', '"learner"')
def wait_for(self, sql, value):
"""Waits (unprinted) until a query returns the value, for example after a reload."""
for _ in range(300):
if self.query(sql) == value:
return
time.sleep(0.1)
sys.exit(f"{sql} never returned {value}")
def show_file(self, path):
"""Prints a text file of the cluster, as `cat` would."""
print(f"$ cat {path}")
print((self.base / path).read_text().rstrip("\n"))
print()
def query(self, sql, port=None):
"""One value from one query, unprinted (the session uses it to wait or to decide)."""
env = {**self.env, "PGPORT": str(port or self.port)}
code, out = self._call(["psql", "-X", "-At", "-c", sql], env=env)
if code != 0:
sys.exit(f"the query failed: {out}")
return out.strip()
def silent(self, sql):
"""Runs a statement without printing it (used only to give a role the run's password)."""
self.query(sql)
def psql(self, script, errors=0, status=None, user=None, db=None, port=None, flags=("-a",), note=None):
"""Runs a psql script with -X (no ~/.psqlrc) and the given flags, and prints the command and its output.
`errors` is how many ERROR lines the script must print; `status` the exit status it must end with."""
env = {**self.env, "PGPORT": str(port or self.port)}
if user:
env["PGUSER"] = user
if db:
env["PGDATABASE"] = db
args = ["psql", "-X", *flags, "-f", script]
code, out = self._call(args, env=env)
shown = ["psql", "-X", *flags, "-f", script]
if user:
shown[1:1] = ["-U", user]
if db:
shown[1:1] = ["-d", db]
if note:
print(f"-- {note}")
_show(shown, out)
found = sum(1 for line in out.splitlines() if "ERROR:" in line or ": error:" in line)
expected_status = 0 if status is None else status
if found != errors or code != expected_status:
sys.exit(f"{script}: {found} errors and status {code}; the session expects {errors} and {expected_status}")
def sessions(self, count):
"""Opens `count` idle psql connections (each one a client backend) until the with block ends."""
return _Idle(self, count)
def shell(self, *args, status=0):
"""Runs one of PostgreSQL's programs and prints the command and its output."""
return self._expect(list(args), show=True, status=status)
def listing(self, folder, prefix=""):
"""Prints the names in a folder of the cluster (sorted, folders with a slash), as `ls -p` would list them."""
path = self.base / folder
names = sorted(n + ("/" if (path / n).is_dir() else "") for n in os.listdir(path) if n.startswith(prefix))
print(f"-- in {folder}" + (f", the names that start with {prefix}" if prefix else "") + ":")
print(textwrap.fill(" ".join(names), width=78, initial_indent=" ", subsequent_indent=" ",
break_on_hyphens=False))
print()
return names
def _arg(a):
"""A command-line argument as a reader would type it: double quotes around text with single quotes in it."""
a = str(a)
if re.fullmatch(r"[\w@%+=:,./-]+", a):
return a
if not re.search(r"[\"$`\\!]", a):
return f'"{a}"'
return shlex.quote(a)
def _show(args, out):
print("$ " + " ".join(_arg(a) for a in args))
if out.strip():
print(out.rstrip("\n"))
print()
class _Idle:
"""Idle psql connections, closed again when the with block ends."""
def __init__(self, server, count):
self.server, self.count, self.procs = server, count, []
def __enter__(self):
before = int(self.server.query("SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend'"))
for _ in range(self.count):
self.procs.append(subprocess.Popen([str(BIN / "psql"), "-X", "-q"], stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
env=self.server.env, cwd=self.server.base))
sql = "SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend'"
for _ in range(300):
# the query's own connection is one more client backend
if int(self.server.query(sql)) == before + self.count:
return self
time.sleep(0.1)
sys.exit("the idle connections did not open")
def __exit__(self, *exc):
for p in self.procs:
p.stdin.close()
p.wait(timeout=30) The first answer counts three client backends: the two idle sessions and the psql that asked. A connection that does
nothing still holds a process, with its own memory, for as long as it stays open. That is why a server allows a
fixed number of them, max_connections, 100 by default, and why applications with thousands of short requests put a
connection pooler in front of PostgreSQL instead of opening a connection per request.
Killing a backend to drop one session
A client backend stopped with kill -9 dies without cleaning up, and the postmaster treats that as a crash: with
restart_after_crash on, the default, it ends every other session and reinitialises the server. End one session
from SQL instead, SELECT pg_terminate_backend(pid) with the pid that pg_stat_activity shows.
Checkpoint
Check: One backend per connection
Background processes do the work no client asks for
The other rows of the first answer are processes that no client started. Each has one job, which the manual describes in the chapter of its feature:
Process (backend_type) |
Its job |
|---|---|
| background writer | Writes changed (“dirty”) pages from the shared buffers to the data files ahead of time, so that backends rarely have to |
| checkpointer | Runs checkpoints: writes every dirty page and marks a point from which crash recovery starts, by default every 5 minutes or before 1 GB of WAL builds up |
| walwriter | Flushes the write-ahead log from its buffers to the files in pg_wal |
| autovacuum launcher | Starts autovacuum workers that clean up old row versions and update statistics |
| io worker (three) | Performs reads and writes for other processes: PostgreSQL 18’s asynchronous I/O, with io_method = worker and io_workers = 3 |
| logical replication launcher | Starts the workers that apply logical replication |
| walsummarizer | Summarises WAL for incremental backups; it runs only with summarize_wal = on, which this session set |
The second answer, after a standby connected, has one more row: a walsender: A process on a PostgreSQL primary that streams write-ahead log to one standby or logical replication subscriber; the primary runs one for each., the process
that streams WAL to that standby, one per standby. The standby’s own list is shorter and different. It runs a
startup process, which replays the WAL it receives, and a walreceiver, which receives it, and it has no
walwriter, autovacuum launcher or logical replication launcher, because a standby accepts no writes and generates no
WAL of its own. An archiver appears only when archive_mode is on.
New in PostgreSQL 18, changed in 19
The three io worker processes are new in PostgreSQL 18, which added asynchronous I/O. The release notes of
PostgreSQL 19, still in preparation, let the server start and stop I/O workers between new settings
io_min_workers and io_max_workers, so their count will vary on a version 19 server.
Checkpoint
Check: The background processes
Shared memory is sized at start, query memory per operation
Two kinds of memory serve those processes. Shared memory is set aside once, when the postmaster starts, and every
process uses it. Most of it is the shared buffers: PostgreSQL's cache of table and index pages in shared memory, used by every process of the server. Its size, shared_buffers (128MB by default), changes only with a restart., PostgreSQL’s cache of table and
index pages, and a smaller part holds the WAL buffers, where changes wait before the walwriter or a committing backend
writes them out. Each backend also takes private memory for what it does itself, above all work_mem for each sort or
hash. The run reads the settings and then sorts 200,000 rows twice:
memory_session.py
"""Shared and per-query memory on a PostgreSQL 18.6 server with its default settings."""
from pg_session import Server
with Server() as pg:
pg.up()
pg.psql("memory.sql") Output
$ psql -X -a -f memory.sql
-- Memory every process shares, sized when the server starts, and memory each query takes for itself.
SELECT name, setting, unit, context FROM pg_settings
WHERE name IN ('shared_buffers', 'wal_buffers', 'work_mem', 'maintenance_work_mem', 'temp_buffers',
'effective_cache_size', 'max_connections')
ORDER BY name;
name | setting | unit | context
----------------------+---------+------+------------
effective_cache_size | 524288 | 8kB | user
maintenance_work_mem | 65536 | kB | user
max_connections | 100 | | postmaster
shared_buffers | 16384 | 8kB | postmaster
temp_buffers | 1024 | 8kB | user
wal_buffers | 512 | 8kB | postmaster
work_mem | 4096 | kB | user
(7 rows)
-- shared_buffers is counted in pages of block_size bytes: 16384 pages of 8192 bytes.
SELECT current_setting('block_size') AS block_size,
pg_size_pretty(16384 * current_setting('block_size')::bigint) AS shared_buffers;
block_size | shared_buffers
------------+----------------
8192 | 128 MB
(1 row)
SHOW shared_memory_size;
shared_memory_size
--------------------
150MB
(1 row)
SELECT name, pg_size_pretty(allocated_size) AS size FROM pg_shmem_allocations
WHERE name IN ('Buffer Blocks', 'XLOG Ctl') ORDER BY allocated_size DESC;
name | size
---------------+---------
Buffer Blocks | 128 MB
XLOG Ctl | 4110 kB
(2 rows)
-- One sort of 200,000 rows with the default work_mem, then with 64MB.
CREATE TABLE readings AS SELECT n AS id, md5(n::text) AS tag FROM generate_series(1, 200000) AS n;
SELECT 200000
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS OFF) SELECT * FROM readings ORDER BY tag;
QUERY PLAN
------------------------------------------------------------
Sort (actual rows=200000.00 loops=1)
Sort Key: tag
Sort Method: external merge Disk: 9208kB
-> Seq Scan on readings (actual rows=200000.00 loops=1)
(4 rows)
SET work_mem = '64MB';
SET
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS OFF) SELECT * FROM readings ORDER BY tag;
QUERY PLAN
------------------------------------------------------------
Sort (actual rows=200000.00 loops=1)
Sort Key: tag
Sort Method: quicksort Memory: 17082kB
-> Seq Scan on readings (actual rows=200000.00 loops=1)
(4 rows)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 memory_session.py
memory.sql
-- Memory every process shares, sized when the server starts, and memory each query takes for itself.
SELECT name, setting, unit, context FROM pg_settings
WHERE name IN ('shared_buffers', 'wal_buffers', 'work_mem', 'maintenance_work_mem', 'temp_buffers',
'effective_cache_size', 'max_connections')
ORDER BY name;
-- shared_buffers is counted in pages of block_size bytes: 16384 pages of 8192 bytes.
SELECT current_setting('block_size') AS block_size,
pg_size_pretty(16384 * current_setting('block_size')::bigint) AS shared_buffers;
SHOW shared_memory_size;
SELECT name, pg_size_pretty(allocated_size) AS size FROM pg_shmem_allocations
WHERE name IN ('Buffer Blocks', 'XLOG Ctl') ORDER BY allocated_size DESC;
-- One sort of 200,000 rows with the default work_mem, then with 64MB.
CREATE TABLE readings AS SELECT n AS id, md5(n::text) AS tag FROM generate_series(1, 200000) AS n;
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS OFF) SELECT * FROM readings ORDER BY tag;
SET work_mem = '64MB';
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS OFF) SELECT * FROM readings ORDER BY tag; shared_buffers is counted in pages: 16,384 pages of 8,192 bytes are 128 MB, and the context postmaster means it
changes only with a restart. wal_buffers was left at its automatic size, 1/32 of the shared buffers, 512 pages or
4 MB, part of the 4,110 kB block called XLOG Ctl. With the rest of what the server keeps in shared memory, the total
shared_memory_size is 150MB.
Under the shared buffers the operating system keeps its own cache of recently read file pages, which PostgreSQL also
relies on. The manual suggests 25 % of a dedicated server’s memory as a first value for shared_buffers and, because
of that second cache, rarely more than 40 %. effective_cache_size, 4GB by default, is only the planner’s assumption
about how much of both caches a query can count on. It allocates nothing.
The two plans show what work_mem is. At the default 4MB the sort of 200,000 rows wrote 9,208 kB of temporary files
and merged them (external merge). At 64MB it sorted in memory, using 17,082 kB. Follow that one sort through the
memory and the files:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Rows come in. The backend scans readings. Each page of the table comes through the shared buffers, which hold pages for every process of the server; a page that is not there yet is read from the table's file.
- The sort fills work_mem. The rows go into the sort's own memory. work_mem, 4MB by default, is the most this one sort may use before it writes to disk.
- Too big for 4MB. The 200,000 rows need about 17MB in memory, four times the limit. So the sort sorts what fits, writes that run to a temporary file, and starts the next run.
- An external merge. When every row has been written in sorted runs, the sort merges the runs from the files. EXPLAIN ANALYZE reports it as external merge, Disk 9208kB.
- With 64MB instead. SET work_mem = '64MB' in the same session gives this sort room for all its rows. It sorts in memory with quicksort and EXPLAIN reports Memory 17082kB; no temporary file is written.
- Only this sort. The shared buffers did not change size. work_mem is taken by each sort or hash operation of each query, so many sessions with a large work_mem can use many times its value at once.
work_mem is a limit for each sort or hash operation, not for a query or a session. The manual warns that a complex
query may run several such operations at once, in each of many sessions, so the total “could be many times” the
value. Raise it for the one report that needs it, never for the whole server first.
Key idea
Shared buffers are one cache of pages that every process shares, sized when the server starts; work_mem is taken again by every sort and hash of every session, so its real total is the value times the operations running at once.
Checkpoint
Check: Shared memory and work_mem
A table is a file named after its relfilenode
Everything the server stores lives in one folder, the data directory: The folder that initdb creates and a PostgreSQL server keeps all its data in: one folder per database under base, the write-ahead log in pg_wal and, by default, the configuration files., which initdb
creates. Each database has a folder under base, named after the database’s OID, and each table and index is a file
in it, named after the table’s relfilenode: The number that names a table's or index's files on disk (pg_class.relfilenode). It often equals the relation's OID at first, but TRUNCATE, REINDEX and table rewrites give a relation a new one.. The run creates a table of 10,000 orders and
finds its file; the next section reads the rest of its output:
files_session.py
"""Where one table's rows live on disk, and the forks that appear beside its main file."""
import os
from pg_session import Server
# Autovacuum is off on this server so that only the session's own VACUUM runs.
with Server(settings={"autovacuum": "off"}) as pg:
pg.up()
pg.psql("files.sql")
folder, name = os.path.split(pg.query("SELECT pg_relation_filepath('orders')"))
pg.listing(f"data/{folder}", prefix=name)
pg.psql("vacuum.sql")
pg.listing(f"data/{folder}", prefix=name)
pg.psql("truncate.sql")
pg.listing("data")
pg.listing("data/pg_wal") Output
$ psql -X -a -f files.sql
-- A table of 10,000 orders, and the file that holds it.
CREATE TABLE orders (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, customer text NOT NULL,
total numeric(10,2) NOT NULL);
CREATE TABLE
INSERT INTO orders (customer, total)
SELECT 'customer ' || n, (n % 500) + 0.5 FROM generate_series(1, 10000) AS n;
INSERT 0 10000
SELECT oid, datname FROM pg_database ORDER BY oid;
oid | datname
-----+-----------
1 | template1
4 | template0
5 | postgres
(3 rows)
SELECT oid, relfilenode, pg_relation_filepath('orders') AS path FROM pg_class WHERE relname = 'orders';
oid | relfilenode | path
-------+-------------+--------------
16385 | 16385 | base/5/16385
(1 row)
SELECT pg_relation_size('orders', 'main') AS main, pg_relation_size('orders', 'fsm') AS fsm,
pg_relation_size('orders', 'vm') AS vm;
main | fsm | vm
--------+-------+----
606208 | 24576 | 0
(1 row)
-- in data/base/5, the names that start with 16385:
16385 16385_fsm
$ psql -X -a -f vacuum.sql
-- VACUUM records which pages hold only rows every transaction can see: that needs the visibility map.
VACUUM orders;
VACUUM
SELECT pg_relation_size('orders', 'main') AS main, pg_relation_size('orders', 'fsm') AS fsm,
pg_relation_size('orders', 'vm') AS vm;
main | fsm | vm
--------+-------+------
606208 | 24576 | 8192
(1 row)
-- in data/base/5, the names that start with 16385:
16385 16385_fsm 16385_vm
$ psql -X -a -f truncate.sql
-- TRUNCATE gives the table a new, empty file: the OID stays, the file name (relfilenode) changes.
TRUNCATE orders;
TRUNCATE TABLE
SELECT oid, relfilenode, pg_relation_filepath('orders') AS path FROM pg_class WHERE relname = 'orders';
oid | relfilenode | path
-------+-------------+--------------
16385 | 16395 | base/5/16395
(1 row)
-- in data:
PG_VERSION base/ global/ pg_commit_ts/ pg_dynshmem/ pg_hba.conf
pg_ident.conf pg_logical/ pg_multixact/ pg_notify/ pg_replslot/
pg_serial/ pg_snapshots/ pg_stat/ pg_stat_tmp/ pg_subtrans/ pg_tblspc/
pg_twophase/ pg_wal/ pg_xact/ postgresql.auto.conf postgresql.conf
postmaster.opts postmaster.pid
-- in data/pg_wal:
000000010000000000000001 archive_status/ summaries/
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 files_session.py
files.sql
-- A table of 10,000 orders, and the file that holds it.
CREATE TABLE orders (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, customer text NOT NULL,
total numeric(10,2) NOT NULL);
INSERT INTO orders (customer, total)
SELECT 'customer ' || n, (n % 500) + 0.5 FROM generate_series(1, 10000) AS n;
SELECT oid, datname FROM pg_database ORDER BY oid;
SELECT oid, relfilenode, pg_relation_filepath('orders') AS path FROM pg_class WHERE relname = 'orders';
SELECT pg_relation_size('orders', 'main') AS main, pg_relation_size('orders', 'fsm') AS fsm,
pg_relation_size('orders', 'vm') AS vm; vacuum.sql
-- VACUUM records which pages hold only rows every transaction can see: that needs the visibility map.
VACUUM orders;
SELECT pg_relation_size('orders', 'main') AS main, pg_relation_size('orders', 'fsm') AS fsm,
pg_relation_size('orders', 'vm') AS vm; truncate.sql
-- TRUNCATE gives the table a new, empty file: the OID stays, the file name (relfilenode) changes.
TRUNCATE orders;
SELECT oid, relfilenode, pg_relation_filepath('orders') AS path FROM pg_class WHERE relname = 'orders'; The database postgres has OID 5, so its tables live in base/5, and orders is the file base/5/16385, as
pg_relation_filepath reports. The file holds 606,208 bytes, 74 pages of 8 kB. A file never grows past 1 GB, the default segment
size: a larger table continues in 16385.1, 16385.2 and so on. The last listings show the top of the data directory, whose parts
you will meet in this track:
| Name | What it holds |
|---|---|
base/ |
One folder per database, named by its OID, with its tables and indexes |
global/ |
Tables shared by the whole cluster, such as pg_database |
pg_wal/ |
The write-ahead log, in segment files of 16 MB, here 000000010000000000000001 |
pg_xact/ |
Whether each transaction committed or aborted |
postgresql.conf, pg_hba.conf |
The settings, and the rules for who may connect and how |
postgresql.auto.conf |
Settings written by ALTER SYSTEM, which override postgresql.conf |
postmaster.pid |
The postmaster’s process ID and port while the server runs |
The configuration files sit in the data directory by default, but a server can be started with config_file and
hba_file pointing elsewhere, and some packaged installations do that. SHOW config_file and SHOW hba_file tell you
where your server reads them from.
Never edit or delete files in the data directory by hand
The server knows things about these files that the files do not say: which pages are dirty in shared memory, which
WAL is still needed. Deleting “old” files in pg_wal to free disk space can leave a cluster that cannot start.
Change data and settings through the server.
Checkpoint
Check: Tables as files in the data directory
Forks appear beside the main file when they are needed
A table’s rows are its main fork: One of the files that store a table or index: the main fork holds its rows or entries, and beside it may be a free space map (_fsm), a visibility map (_vm) and, for unlogged relations, an initialization fork (_init)., the file 16385. Beside it the server keeps other
forks with the same name and a suffix. The free space map: A fork of a table or index (its file ends in _fsm) that records how much room each page has left, so that new rows can be placed without reading the whole relation., _fsm, records how much
room each page has left, so an insert can find space without reading the whole table. The
visibility map: A fork of a table (its file ends in _vm) with two bits per page: whether every row on the page is visible to all transactions, and whether every row is frozen. VACUUM and index-only scans use it., _vm, keeps two bits per page: whether every row on the page is
visible to every transaction, and whether every row is frozen. Index-only scans and VACUUM use it to skip pages.
The recorded run shows them appear. After the insert, orders had a 24,576-byte free space map (three pages) but a
visibility map of 0 bytes: no file at all, as the first listing shows. The session’s VACUUM checked every page and
recorded that all of them are all-visible, which needs the visibility map, and the file 16385_vm of 8,192 bytes
appeared in the second listing. Unlogged tables and indexes also have an initialization fork, _init.
The last step shows why the file name and the table’s OID are two numbers. TRUNCATE gave orders a new, empty file:
the OID stayed 16385, and the relfilenode became 16395. The manual warns that TRUNCATE, REINDEX, CLUSTER and some
forms of ALTER TABLE do this, so never assume the two are equal; pg_relation_filepath always gives the current
path of the first segment of the main fork. Sorts and hashes that spill, like the 9,208 kB sort above, write their
temporary files in base/pgsql_tmp.
Checkpoint
Check: Forks and relfilenodes
Practise: read a server from its processes and files
Every question below comes from the recorded runs above: the process lists, the memory settings and the files of
orders. The session begins with the idea your answers so far have mastered least:
Practice that adapts to you
Practise this lesson
11 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
On your own server
Run SELECT backend_type, count(*) FROM pg_stat_activity GROUP BY 1; on a server you look after, then
SELECT pg_relation_filepath('your_table'); and compare pg_relation_size('your_table', 'vm') before and after a
VACUUM of a newly loaded table.
Interview questions
Warm-up (fresher): What happens inside PostgreSQL when a client connects, and why does it matter for the number of connections?
The postmaster, the server’s supervisor process, accepts the connection and starts a new process for it, a client backend. The backend authenticates the client using pg_hba.conf, loads the settings for its role and database and then runs every query of that session; the postmaster goes back to listening. Each connection therefore costs a whole process with its own private memory, even when it is idle, which is why max_connections (100 by default) is a fixed limit and why applications with many short requests use a connection pooler. When the client disconnects, its backend exits and its memory is released, while the postmaster and the background processes keep running.
Two questions go further: memory that a server runs out of although its shared buffers are small, and a file in the data directory that nobody can name. Each has a first step and follow-ups on this module’s question page:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A server with 32 GB of RAM runs PostgreSQL with shared_buffers = 8GB, max_connections = 500 and work_mem = 64MB set for everyone. During a burst of reports the kernel's out-of-memory killer ends a postgres process and every session drops. How can that happen when shared memory is only 8 GB, and what do you change?
-
Mid to senior
A disk report shows that base/16384/2609183.3 and its neighbours take 40 GB, and nobody knows which table that is. How do you find out, safely, and what do the folder name, the number and the .3 tell you?
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
In PostgreSQL's "process per user" model, what happens when a client connects?
The postmaster listens and starts one client backend process for the connection; from then on the client talks only to that backend while the postmaster returns to listening.
-
Why does every open connection cost memory even when idle?
Each connection holds its own backend process with private memory for as long as it stays open. That is why max_connections (100 by default) is fixed and why apps with many short requests use a connection pooler.
-
How should you end a single PostgreSQL session, and why not kill -9?
Use SELECT pg_terminate_backend(pid). kill -9 on a backend looks like a crash to the postmaster, which with restart_after_crash on (the default) ends every session and reinitialises the server.
-
How do shared_buffers and work_mem differ?
shared_buffers is one page cache (128 MB by default) shared by every process, sized at startup and changed only by restart. work_mem is a per-sort/per-hash limit taken again by every operation, so its real total is the value times the operations running at once.
-
Where is a table's file stored and what is it named after?
In base/<database OID>/, named after the table's relfilenode and split into 1 GB segments. pg_relation_filepath gives the current path; TRUNCATE, REINDEX and CLUSTER change the relfilenode while the OID stays.
-
What do the free space map (_fsm) and visibility map (_vm) forks hold?
The free space map records how much room each page has left so inserts find space without scanning. The visibility map keeps two bits per page (all-visible, all-frozen), appears once VACUUM records in it, and lets index-only scans and VACUUM skip pages.
Key takeaways
- The postmaster starts one client backend per connection, so every open connection, idle or busy, costs a process and its private memory.
- Background processes do the server’s own work: the background writer and the checkpointer write pages, the walwriter writes WAL, three io workers serve asynchronous I/O in PostgreSQL 18, and a standby runs startup and walreceiver instead.
- Shared buffers, 128 MB by default, are one cache of pages that every process shares and that changes only with a restart, while work_mem is a limit for each sort or hash of each session.
- A table is a file in
base/<database OID>/named after its relfilenode, split into 1 GB segments, andpg_relation_filepathgives its current path. - The free space map and the visibility map are forks beside the main file; the visibility map appears once VACUUM has something to record in it, and operations such as TRUNCATE give a table a new relfilenode while its OID stays.
Terms in this lesson
- client backend
- The server process that PostgreSQL starts for one client connection. It authenticates the client and runs every query of that session, and it exits when the client disconnects.
- data directory
- The folder that initdb creates and a PostgreSQL server keeps all its data in: one folder per database under base, the write-ahead log in pg_wal and, by default, the configuration files.
- fork
- One of the files that store a table or index: the main fork holds its rows or entries, and beside it may be a free space map (_fsm), a visibility map (_vm) and, for unlogged relations, an initialization fork (_init).
- free space map
- A fork of a table or index (its file ends in _fsm) that records how much room each page has left, so that new rows can be placed without reading the whole relation.
- postmaster
- The supervisor process of a PostgreSQL server. It starts the background processes, listens for connections and starts a client backend for each one; it runs no queries itself.
- relfilenode
- The number that names a table's or index's files on disk (pg_class.relfilenode). It often equals the relation's OID at first, but TRUNCATE, REINDEX and table rewrites give a relation a new one.
- shared buffers
- PostgreSQL's cache of table and index pages in shared memory, used by every process of the server. Its size, shared_buffers (128MB by default), changes only with a restart.
- visibility map
- A fork of a table (its file ends in _vm) with two bits per page: whether every row on the page is visible to all transactions, and whether every row is frozen. VACUUM and index-only scans use it.
- walsender
- A process on a PostgreSQL primary that streams write-ahead log to one standby or logical replication subscriber; the primary runs one for each.
References
- PostgreSQL 18 documentation: 1.2 Architectural Fundamentals (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 51.2 How Connections Are Established (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 19.4 Resource Consumption (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 19.5 Write Ahead Log (wal_buffers) (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 19.7 Query Planning (effective_cache_size) (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 66.1 Database File Layout (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 66.3 Free Space Map (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 66.4 Visibility Map (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 19.2 File Locations (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 27.2 The Cumulative Statistics System (pg_stat_activity) (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 28.5 WAL Configuration (checkpoints) (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 18.5 Shutting Down the Server (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 19.14 Error Handling (restart_after_crash) (The PostgreSQL Global Development Group)
- PostgreSQL 18 release notes (The PostgreSQL Global Development Group)
- PostgreSQL 19 release notes (in preparation) (The PostgreSQL Global Development Group)
Report a problem with this lesson
Kept only in this browser. Your Learn progress