Expedition. Free, on-demand.

Technical tracks for practitioners, outcomes for leaders.

Process Model

When you start Postgres, the first thing that runs is the postmaster process. It's the supervisor: it listens for incoming connections and starts and watches over everything else.

When a client connects, the postmaster forks a new backend process just for that connection. One process per connection. That backend parses, plans, and executes queries for that session, and it exits when the connection closes.

This is why connection pooling matters in production. If 500 application servers each open a connection, you get 500 backend processes eating memory and CPU, most of them sitting idle. Postgres doesn't ship a built-in pooler, so most people put PgBouncer (or a pooler provided by their platform) in front of it.

Beyond client backends, Postgres runs a set of background processes:

ProcessRole
autovacuum launcherStarts autovacuum workers to clean up dead tuples
checkpointerWrites dirty buffers to disk at checkpoints
WAL writerFlushes WAL buffers to disk
background writerWrites some dirty pages ahead of time to smooth out checkpoint spikes
io workerPerforms asynchronous I/O on behalf of backends (Postgres 18+, when io_method = worker)
logical replication launcher / workersRun logical replication subscriptions

If you've read older guides, you might be looking for a "stats collector." That process is gone as of Postgres 15. Cumulative statistics now live in shared memory.

Postgres 18 added asynchronous I/O. The io_method setting controls it: worker (the default) hands reads to a pool of I/O worker processes sized by io_workers, io_uring uses Linux's io_uring interface, and sync keeps the old synchronous behavior. Sequential scans, bitmap heap scans, and vacuum are the main beneficiaries so far.

You can see all of these processes live:

SELECT pid, backend_type, state
FROM pg_stat_activity
ORDER BY backend_type;
  pid  |      backend_type      | state
-------+------------------------+--------
 12301 | autovacuum launcher    |
 12298 | background writer      |
 12297 | checkpointer           |
 12303 | client backend         | active
 12304 | client backend         | idle
 12305 | io worker              |
 12299 | walwriter              |

Postgres tracks its own processes in system views, and you'll see that pattern all through this guide.

Snowflake Postgres runs and configures these processes for you, including memory settings like shared_buffers, WAL archiving, and backups. Knowing the architecture still pays off when you're reading EXPLAIN plans, chasing a slow query, or watching activity in Snowflake Postgres Insights.

Shared Memory and the Buffer Cache

All those backend processes share data through shared memory. The biggest piece of it is the buffer cache, sized by shared_buffers.

The buffer cache holds 8KB pages, the basic unit of storage in Postgres. When a query needs a row, Postgres first checks whether the page holding it is already in the buffer cache. If it is, no disk read. If not, it reads the page in (often from the operating system's page cache, which is why Postgres doesn't need all of RAM for itself).

A common starting point is shared_buffers at about 25% of RAM:

SELECT name, setting, unit, short_desc
FROM pg_settings
WHERE name = 'shared_buffers';
     name      | setting | unit |                 short_desc
---------------+---------+------+--------------------------------------------
 shared_buffers | 16384  | 8kB  | Sets the number of shared memory buffers.

That 16384 is 16,384 pages of 8KB each, so 128MB, which is the out-of-the-box default. On a production server with 64GB of RAM, you'd likely set it around 16GB.

Two background processes move data between the cache and disk. The background writer periodically finds "dirty" pages (modified but not yet written) and writes some of them out to smooth I/O over time. The checkpointer writes out all dirty pages at each checkpoint, which bounds how much WAL has to be replayed after a crash.

Here's how to check how well the cache is doing:

SELECT
  blks_hit,
  blks_read,
  round(blks_hit::numeric / nullif(blks_hit + blks_read, 0) * 100, 2) AS cache_hit_ratio
FROM pg_stat_database
WHERE datname = current_database();
 blks_hit  | blks_read | cache_hit_ratio
-----------+-----------+-----------------
 584729103 |    234891 |           99.96

For a transactional workload, you want this above 99%. If it drops noticeably, your working set has probably outgrown shared_buffers. Analytics workloads that scan big tables will naturally run lower.

MVCC: Multi-Version Concurrency Control

Postgres handles concurrency with MVCC: readers don't block writers and writers don't block readers. It gets there by keeping multiple versions of each row.

Every row has hidden system columns, including:

  • xmin, the ID of the transaction that created this row version
  • xmax, the ID of the transaction that deleted or updated this row version (or locked it), or 0 if none

You can select them directly:

SELECT xmin, xmax, id, name
FROM customers
LIMIT 5;
  xmin  |  xmax  | id |    name
--------+--------+----+------------
 945021 |      0 |  1 | Acme Corp
 945021 |      0 |  2 | Widgets Inc
 945098 |      0 |  3 | DataCo
 945098 | 945110 |  4 | OldClient
 945102 |      0 |  5 | NewStartup

Row 4 has a non-zero xmax. If transaction 945110 committed a delete or update, that row version is dead to new snapshots, but it physically stays on disk until vacuum cleans it up. (A non-zero xmax can also just mean a row lock or a transaction that rolled back, so don't read too much into it on its own.)

When you UPDATE a row, Postgres doesn't change it in place. It sets the old version's xmax and inserts a new version with a new xmin. That's why updates create "dead tuples": old row versions no current transaction can see, still taking up space.

Check how many dead tuples have built up:

SELECT
  relname,
  n_live_tup,
  n_dead_tup,
  round(n_dead_tup::numeric / greatest(n_live_tup, 1) * 100, 1) AS dead_pct,
  last_autovacuum
FROM pg_stat_user_tables
WHERE n_dead_tup > 1000
ORDER BY n_dead_tup DESC
LIMIT 5;
   relname    | n_live_tup | n_dead_tup | dead_pct |     last_autovacuum
--------------+------------+------------+----------+-------------------------
 events       |   2340891  |     89234  |     3.8  | 2026-10-15 03:22:14-05
 sessions     |    450123  |     34521  |     7.7  | 2026-10-15 02:45:01-05
 audit_log    |   8901234  |     12003  |     0.1  | 2026-10-14 22:10:33-05

This is why vacuum exists. It reclaims space from dead tuples so tables don't grow forever. Autovacuum takes care of it automatically, but knowing the mechanism helps when you're diagnosing bloat. The Vacuum guide goes much deeper.

Write-Ahead Logging and Storage

The Write-Ahead Log (WAL) is how Postgres guarantees durability. Every change is written to the WAL before it's written to the data files.

The reason: WAL writes are sequential (fast) and data file writes are random (slower). Logging changes sequentially first lets Postgres acknowledge a COMMIT as soon as the WAL record is flushed, write the actual data pages later in the background, and recover from a crash by replaying WAL from the last checkpoint.

Check the current WAL position:

SELECT
  pg_current_wal_lsn() AS current_lsn,
  round(pg_wal_lsn_diff(pg_current_wal_lsn(), '0/0') / (1024*1024*1024), 2) AS total_wal_gb;
 current_lsn | total_wal_gb
-------------+--------------
 3/A4F01C28  |        14.65

That's the total amount of WAL this cluster has ever generated, since the LSN only goes up.

WAL also powers streaming replication. Replicas receive and replay WAL records to stay in sync with the primary, using the same machinery as crash recovery. The WAL guide covers checkpoints, archiving, and point-in-time recovery.

Pages and TOAST

On disk, Postgres stores everything in 8KB pages, and a row can't span pages. So what happens with a big value? TOAST, The Oversized-Attribute Storage Technique.

TOAST kicks in automatically when a row gets wide, by default once it passes about 2KB. Postgres compresses large column values and, if they're still too big, moves them out to a separate TOAST table. You don't have to do anything.

Each column has a storage strategy:

StrategyBehavior
PLAINNo compression or out-of-line storage (used for fixed-length types)
EXTENDEDCompress first, then store out-of-line if still too big
EXTERNALStore out-of-line without compression
MAINCompress, and move out-of-line only as a last resort

Check the strategies for a table's columns:

SELECT
  a.attname,
  t.typname,
  CASE a.attstorage
    WHEN 'p' THEN 'PLAIN'
    WHEN 'x' THEN 'EXTENDED'
    WHEN 'e' THEN 'EXTERNAL'
    WHEN 'm' THEN 'MAIN'
  END AS toast_strategy
FROM pg_attribute a
JOIN pg_type t ON a.atttypid = t.oid
WHERE a.attrelid = 'documents'::regclass
  AND a.attnum > 0
  AND NOT a.attisdropped
ORDER BY a.attnum;
  attname   |   typname   | toast_strategy
------------+-------------+-----------------
 id         | int8        | PLAIN
 title      | varchar     | EXTENDED
 body       | text        | EXTENDED
 metadata   | jsonb       | EXTENDED
 created_at | timestamptz | PLAIN

In practice: large text, jsonb, and bytea values get TOASTed automatically. If you SELECT * from a table with big columns you don't need, you're paying to fetch and decompress them from the TOAST table for nothing. List the columns you want.

The System Catalog

One of our favorite things about Postgres is that it describes itself with its own tables. The pg_catalog schema holds metadata for everything in your database (tables, columns, indexes, functions), and the statistics views cover settings, activity, and running queries. All of it is queryable with plain SQL.

Object hierarchy

Postgres organizes objects as Instance → Database → Schema → Objects. One Postgres instance (a "cluster" in Postgres terms) can host several databases. Each database has schemas, and each schema holds tables, views, functions, and other objects.

Every database has a public schema by default, and search_path controls which schemas Postgres checks when you use an unqualified table name:

SHOW search_path;
   search_path
-----------------
 "$user", public

Postgres looks first for a schema named after your user, then falls back to public.

Key catalog views for daily work

What's running right now:

SELECT pid, state, query_start, left(query, 60) AS query
FROM pg_stat_activity
WHERE state = 'active'
  AND pid != pg_backend_pid();
  pid  | state  |       query_start        |            query
-------+--------+--------------------------+-----------------------------
 14502 | active | 2026-10-15 10:22:01-05   | SELECT o.id, o.total FROM orders o JOIN...
 14891 | active | 2026-10-15 10:22:03-05   | UPDATE inventory SET quantity = quanti...

Table statistics and health:

SELECT
  schemaname,
  relname,
  seq_scan,
  idx_scan,
  n_tup_ins,
  n_tup_upd,
  n_tup_del
FROM pg_stat_user_tables
ORDER BY seq_scan DESC
LIMIT 5;
 schemaname |  relname  | seq_scan | idx_scan | n_tup_ins | n_tup_upd | n_tup_del
------------+-----------+----------+----------+-----------+-----------+-----------
 public     | events    |    45021 |  2340891 |   1203445 |     89201 |     12034
 public     | sessions  |    12003 |   890234 |    450123 |    234521 |    190234
 public     | users     |     3421 |  4502341 |     12034 |     89012 |       234

Lots of sequential scans on a large table usually means a missing index. On small tables, seq scans are fine and often faster.

Index usage:

SELECT
  indexrelname,
  idx_scan,
  idx_tup_read,
  idx_tup_fetch,
  pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
ORDER BY idx_scan ASC
LIMIT 5;
      indexrelname       | idx_scan | idx_tup_read | idx_tup_fetch |  size
-------------------------+----------+--------------+---------------+---------
 idx_events_legacy_type  |        0 |            0 |             0 | 245 MB
 idx_orders_old_status   |       12 |           48 |            48 | 128 MB
 idx_sessions_temp       |      891 |         4201 |          4201 | 64 MB

Indexes with zero scans waste disk and slow down every write. Before dropping one, make sure it isn't backing a primary key or unique constraint, and check replicas too, since these stats are per server.

Configuration inspection:

SELECT name, setting, unit, source
FROM pg_settings
WHERE source != 'default'
  AND name NOT LIKE 'lc_%'
ORDER BY name;

This lists every setting that's been changed from its default, which is the fastest way we know to see how an instance has been tuned.

Conclusion

The process model, shared memory, MVCC, WAL, and the system catalog explain most of what you'll run into day to day: why you need a connection pooler, how to size memory, why tables bloat, and what an EXPLAIN plan is really telling you. When something looks off, start by querying the catalog. Postgres is already keeping track of itself in tables you know how to query.

Related Resources

Postgres's internal architecture and processes in depth.


Explore the system catalog and what Postgres tracks about itself.


How Postgres handles oversized data with TOAST storage.


Brian Pace explains how WAL file naming and sequence numbers work under the hood.


Brian Pace walks through WAL history files and timeline recovery scenarios.


How Snowflake runs and manages Postgres for you.

Updated Oct 27, 2026

This content is provided as is, and is not maintained on an ongoing basis. It may be out of date with current Snowflake instances