Notes
What it is
PostgreSQL is an open-source relational database with full transactions. Your services send SQL to one primary server that takes every write, and copies of it can serve reads. Every major cloud runs it for you. And it has a rich extension system: PostGIS for maps, pgvector for embeddings, Timescale for time series, Citus for sharding.
- open-source relational DB,
- full ACID transactions
- one primary takes writes,
- replicas serve reads
- managed: RDS, Aurora, Cloud
- SQL, Azure, Neon, Supabase
- extensions: PostGIS, pgvector,
- TimescaleDB, Citus
Data model
The data model is tables of typed rows. You declare a primary key, foreign keys to other tables, and check constraints, and the database refuses data that breaks them. That is the real value: correctness is enforced in one place, not in every service. When part of a record really is free-form, a JSONB column holds it, and you can still index inside it.
CREATE TABLE orders ( id bigint PRIMARY KEY, user_id bigint REFERENCES users(id), status text NOT NULL, total_cents int CHECK (total_cents >= 0), attrs jsonb, created_at timestamptz DEFAULT now() ); CREATE INDEX ON orders (user_id,created_at);
Write path
Here is the write path. Postgres changes the data page in memory, appends a record describing the change to the write-ahead log, and on commit it flushes that log to disk. Only then does it reply. The actual table pages are written later, in the background. If the server crashes, it replays the log from the last checkpoint. Sequential log writes are why commits are fast and safe.
- 1. change the page in memory
- (shared_buffers)
- 2. append a WAL record
- 3. fsync the WAL on COMMIT
- 4. reply "committed"
- dirty pages reach the table
- files later, at checkpoints
- crash? replay WAL from the
- last checkpoint
MVCC
Postgres uses multi-version concurrency control. An update does not overwrite the row. It writes a new version and marks the old one as dead from that transaction onward. A query that started earlier keeps reading the old version, so readers never block writers. The price is dead rows piling up, which a background process called vacuum cleans. Long-running transactions stop vacuum and cause bloat.
UPDATE orders SET status = 'paid' v1 status=new xmax=901 (dead) v2 status=paid xmin=901 (live) a reader that started before txn 901 still sees v1 readers never block writers VACUUM later frees dead v1
Indexes
Indexes are where most performance comes from. The default B-tree handles equality, ranges and sorting. GIN indexes the inside of JSONB, arrays and full-text documents. GiST handles geometry, which is how PostGIS finds nearby places. BRIN is a tiny index for huge tables ordered by time. Partial and covering indexes let you index only hot rows and answer a query without touching the table. Concepts episode 17 covers why.
- B-tree: equality, ranges, sort
- (the default, most cases)
- GIN: JSONB keys, arrays,
- full-text search
- GiST: geo, ranges (PostGIS)
- BRIN: huge append-only tables
- ordered by time, tiny index
- partial: WHERE status='open'
- covering: INCLUDE (total)
Connections
Something interviewers like to probe: Postgres starts a separate operating system process for every connection, each using a few megabytes. Two thousand app threads connecting directly will hurt. So you put a pooler such as PgBouncer in front. In transaction mode, thousands of clients share about a hundred real connections, each borrowed only for the length of one transaction.
- each connection = an OS process
- a few MB of RAM each
- 2,000 app threads directly
- → memory and context switches
- PgBouncer in transaction mode:
- thousands of clients share
- a hundred real connections
Replication
Replication reuses the same log. Replicas receive the write-ahead log as a stream and replay it, so each is an exact copy that can serve reads. By default this is asynchronous: commits are fast, but a replica may lag, and a failover can lose the last few commits. Synchronous mode makes commit wait for a replica, trading latency for zero data loss. Logical replication copies chosen tables, which helps with upgrades and change data capture.
- replicas receive the WAL and
- replay it: an exact copy
- async (default): fast commit,
- replica may lag, may lose the
- last few commits on failover
- sync: commit waits for a
- replica, zero loss, slower
- logical replication: per table
Failover
When the primary dies, plain Postgres will not promote anyone by itself. A manager does that: Patroni uses etcd to elect a replica and promote it, or your cloud provider handles it, typically within a minute or two on RDS and faster on Aurora. Clients find the new primary through DNS or the pooler. Say this explicitly in an interview, because many people assume it is automatic.
- core Postgres has no built-in
- automatic failover
- Patroni + etcd: elects and
- promotes a replica
- RDS Multi-AZ: about 1-2 min
- Aurora: usually under 30 s
- clients reconnect via DNS
- or the pooler
Scaling writes
One primary takes every write, so how far does it go? Further than most people think. First scale up; a large instance handles a lot. Move reads to replicas and a cache. Partition huge tables by month, so dropping old data is instant. When writes truly exceed one machine, shard: Citus spreads tables across nodes by a distribution key, or your application routes each tenant to its own database. Concepts episode 21 covers sharding.
- 1. bigger box: 64+ cores,
- TBs of RAM go a long way
- 2. read replicas + cache
- 3. partition by time or key
- (drop old months instantly)
- 4. shard: Citus, or app-level
- routing by tenant_id
- writes scale only by
- splitting the data
Great at
What it is great at. Anything where correctness matters: payments, orders, inventory, bookings, where a transaction and a constraint prevent a whole class of bugs. Queries you did not plan for, because SQL and joins are flexible. And breadth: with extensions, one Postgres can handle documents, geo search, vector similarity and basic full-text search, so small teams run fewer systems.
- • money, orders, inventory:
- transactions + constraints
- • joins and ad-hoc queries
- you did not plan for
- • serializable isolation
- when you need it
- • JSONB, geo, vectors, full-
- text in the same database
- • huge ecosystem, every cloud
Where it hurts
Where it hurts. There is one writer, so write throughput has a ceiling, and sharding is something you add, not something it does. Tables updated constantly create dead rows, and if vacuum falls behind they bloat. Too many connections hurt. Replicas lag, so a user may not see their own write. Altering a billion-row table needs care. And active-active writes across regions are not what it was built for.
- • one primary: write ceiling
- • no native auto-sharding
- • vacuum + bloat on hot,
- update-heavy tables
- • process-per-connection
- • replica lag: stale reads
- • schema change on a billion
- rows needs care
- • multi-region writes: hard
Use it when
So, use it when your data has relationships, you need transactions, you will run reports, or you cannot yet predict the queries. That covers most products. Look elsewhere when you need millions of writes a second, writes accepted in several regions at once, or only simple key lookups at enormous scale, where DynamoDB or Cassandra fit better. See the Which database guide for the full table.
- USE when
- relationships, transactions,
- reporting, unknown queries
- data fits a few TB per node
- AVOID when
- millions of writes per second
- multi-region active writes
- pure key lookups at huge scale
- (DynamoDB, Cassandra)
Numbers
Numbers to have ready, assuming a modern server with NVMe disks. Simple indexed reads run from ten thousand to over a hundred thousand a second on one node, and simple writes from roughly five thousand to thirty thousand a second, more with batching. A few terabytes per node is comfortable. Pages are eight kilobytes, the default connection limit is one hundred, and the default isolation level is read committed.
page size 8 KB default max_connections 100 simple indexed reads 10k-100k+/s simple writes 5k-30k+/s (one node, NVMe, batched commits) comfortable size 1-10 TB/node replica lag (same AZ) ms to seconds row update = new row version default isolation READ COMMITTED
Say it like this
Here is how to say it. Orders need transactions and joins, so PostgreSQL. A primary for writes, two asynchronous replicas for reads, a connection pooler in front, and Patroni or the managed service for failover. Index (user_id, created_at) for the order history page. At ten times the writes, partition by month and then shard by user id. That answer shows you know both the strengths and the ceiling.
- "Orders need transactions and
- joins, so PostgreSQL.
- Primary for writes, two async
- replicas for reads, PgBouncer
- in front, Patroni for failover.
- Index (user_id, created_at).
- At 10x writes: partition by
- month, then shard by user_id
- with Citus."