All lessons
DataIntermediate12 min readIn my stack

PostgreSQL

JSONB, CTEs, strong constraints, MVCC, row-level security and Prisma in production.

  • PostgreSQL 17
  • JSONB
  • Prisma
  • Row-level security
Step 1/6
The query: find the user with id = 42. There's no index, so the database reads the table row by row. Already sweating.

In 30 seconds

PostgreSQL is an advanced open-source relational database known for correctness, rich SQL and extensibility. It handles relational data, JSON documents, full-text search and geospatial data in one system, and lets the database enforce complex rules. For complex products, especially multi-tenant SaaS, it is often the safest default.

Why I use it

  • I use PostgreSQL for complex products because strict constraints, transactional DDL and rich SQL let the database protect data integrity, not just store it.
  • I like that JSONB gives me flexible fields where I need them without giving up relational modelling for the rest.
  • I chose it with Prisma for a large multi-tenant SaaS, where a strongly typed schema and reviewed SQL migrations keep a big domain manageable.
  • Its features for tenancy, such as row-level security and partial indexes, give a second line of defence beyond application code.

How it works

01Why Postgres: features that matter

PostgreSQL follows the SQL standard closely and adds a lot on top. Constraints are strong: CHECK, foreign keys that can be deferred to commit time, and EXCLUDE constraints that can forbid overlapping time ranges, such as double bookings. DDL is transactional, so a failed migration rolls back completely. Common table expressions (WITH queries), including recursive ones for trees, keep complex queries readable. Window functions, upserts (INSERT ... ON CONFLICT), RETURNING, arrays, ranges, enums and generated columns are all built in. Extensions add whole capabilities: pg_trgm for fuzzy search, PostGIS for maps, pgvector for embeddings, pg_stat_statements for query stats. PostgreSQL 18 (September 2025) added asynchronous I/O and a native uuidv7() function.

sql
-- recursive CTE: all subcategories of category 1
WITH RECURSIVE tree AS (
  SELECT id, parent_id, name FROM categories WHERE id = 1
  UNION ALL
  SELECT c.id, c.parent_id, c.name
  FROM categories c JOIN tree t ON c.parent_id = t.id
)
SELECT * FROM tree;
 
-- forbid overlapping bookings for the same room
CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE bookings ADD CONSTRAINT no_overlap
  EXCLUDE USING gist (room_id WITH =, period WITH &&);

02JSONB done right

JSONB stores JSON in a parsed binary format that can be indexed and queried. Operators like -> and ->> read fields, @> tests containment, and jsonb_path_query supports JSONPath. It is ideal for truly variable data: product attributes that differ per category, integration payloads, settings or CMS blocks. It is a poor fit for data you filter, join or constrain often; that belongs in real columns with types and foreign keys. A practical pattern is to keep core fields relational and put the long tail in a JSONB column, indexed with GIN. If one JSON field becomes important, promote it to a generated column or a real column. Remember that updating any part of a JSONB value rewrites the whole value.

sql
ALTER TABLE products ADD COLUMN attrs jsonb NOT NULL DEFAULT '{}';
CREATE INDEX products_attrs_gin ON products USING gin (attrs jsonb_path_ops);
 
-- all products with colour "black" and size "M"
SELECT id, name
FROM products
WHERE attrs @> '{"colour": "black", "size": "M"}';

03Indexes: B-tree, GIN and friends

B-tree is the default and handles equality, ranges and sorting; use it for ids, foreign keys, dates and most columns. GIN (generalised inverted index) indexes the elements inside a value, which makes it right for JSONB containment, arrays and full-text search; it is larger and slower to update than B-tree. GiST supports ranges, geometry and exclusion constraints. BRIN is tiny and works for huge, naturally ordered tables like logs. Postgres also has partial indexes (only rows matching a WHERE clause, such as active rows), expression indexes (on lower(email)) and INCLUDE columns for covering indexes. Build indexes on live tables with CREATE INDEX CONCURRENTLY, and check plans with EXPLAIN (ANALYZE, BUFFERS).

  • B-tree: equality, ranges, ORDER BY.
  • GIN: JSONB, arrays, full-text, trigram search.
  • GiST: ranges, geometry, exclusion constraints.
  • Partial and expression indexes: smaller and more precise.

04MVCC, VACUUM and connections

PostgreSQL implements MVCC (multi-version concurrency control) by never updating rows in place. An UPDATE writes a new row version and marks the old one as dead; each transaction sees the versions that were visible when its snapshot was taken. Readers never block writers. The cost is cleanup: dead rows stay until VACUUM removes them, and autovacuum does this in the background. If autovacuum falls behind, tables bloat and queries slow down; long-running transactions make it worse because they prevent cleanup. VACUUM also prevents transaction ID wraparound, so never disable it. The default isolation level is READ COMMITTED. Each connection is a separate server process, so use a pooler such as PgBouncer in transaction mode when many app instances or serverless functions connect.

  • Monitor dead tuples and autovacuum in pg_stat_user_tables.
  • Avoid long idle-in-transaction sessions; set idle_in_transaction_session_timeout.
  • Pool connections; hundreds of direct connections waste memory.

05Row-level security for multi-tenancy

Row-level security (RLS) lets the database filter rows per request. You enable it on a table and write a policy, for example 'a row is visible only if its company_id equals the current tenant setting'. The application sets that setting at the start of each transaction, and then even a query that forgets its WHERE clause cannot see another tenant's data. Important details: table owners and superusers bypass RLS unless you use FORCE ROW LEVEL SECURITY and connect as a dedicated non-owner role; with connection pooling, set the tenant with set_config(..., true) so it is local to the transaction; and index the tenant column because every query now filters by it. RLS works best as a second layer on top of explicit tenant filters in the application.

sql
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
 
CREATE POLICY tenant_isolation ON orders
  USING (company_id = current_setting('app.company_id')::uuid)
  WITH CHECK (company_id = current_setting('app.company_id')::uuid);
 
-- per request, inside a transaction:
BEGIN;
SELECT set_config('app.company_id', '0193a1b2-0000-7000-8000-000000000001', true);
SELECT id, status FROM orders;   -- only this tenant's rows
COMMIT;

06Prisma with PostgreSQL

Prisma is a TypeScript ORM: you describe models in schema.prisma, and it generates a fully typed client and SQL migrations. In development, prisma migrate dev compares the schema with the database, writes a migration file and applies it. In CI and production, run prisma migrate deploy, which only applies committed migrations and never resets data. Use --create-only to generate a migration and edit the SQL before applying it, which is how you add things Prisma's schema cannot fully express, such as RLS policies, CHECK and exclusion constraints, or CREATE INDEX CONCURRENTLY. Client extensions can add cross-cutting rules, such as automatically scoping every query to a tenant. Prisma 7 moved to a Rust-free client with driver adapters and a prisma.config.ts file, so check the upgrade guide when moving from version 6.

prisma
model Order {
  id         String   @id @default(uuid(7)) @db.Uuid
  companyId  String   @db.Uuid
  status     String
  totalMinor BigInt
  createdAt  DateTime @default(now()) @db.Timestamptz
  items      OrderItem[]
 
  @@index([companyId, createdAt(sort: Desc)])
}
 
// npx prisma migrate dev --name add_orders --create-only
// review/edit SQL, then: npx prisma migrate dev
// production:            npx prisma migrate deploy

07Backups, and Postgres vs MySQL

pg_dump makes a logical backup of one database; the custom format (-Fc) is compressed and lets pg_restore restore selectively and in parallel. For larger systems and point-in-time recovery, combine a physical base backup (pg_basebackup) with continuous WAL archiving, ideally through a tool like pgBackRest or WAL-G that handles retention, compression and off-site storage. Test restores regularly. Choosing between Postgres and MySQL: both are excellent for standard web apps. I lean to Postgres for complex domains, strict data integrity, JSONB, advanced SQL, multi-tenancy with RLS, or when I need extensions such as PostGIS or pgvector. I stay with MySQL or MariaDB when the hosting, the team or the framework ecosystem is built around it and the workload is simple reads and writes.

bash
# logical backup, compressed custom format
pg_dump -Fc -U backup_user -h localhost -d shop -f shop-$(date +%F).dump
 
# restore into a fresh database with 4 parallel jobs
createdb -U postgres shop_restore_test
pg_restore -U postgres -d shop_restore_test -j 4 shop-2026-11-05.dump

Use it when

  • Complex domains with many relations and strict integrity rules.
  • Multi-tenant SaaS, where RLS and partial indexes help isolation.
  • Mixed data: relational core plus JSONB, full-text, geospatial or vector search.
  • TypeScript backends with Prisma, Drizzle or another typed ORM.

Skip it when

  • Hosting environments or teams built entirely around MySQL, where switching adds risk without clear benefit.
  • Workloads with huge numbers of short-lived connections and no pooler in place.
  • Pure analytics on very large datasets, where a columnar warehouse fits better.

Alternatives

  • MySQL / MariaDB

    Simpler operations and ubiquitous hosting; great for standard web workloads.

  • Managed Postgres (RDS, Neon, Supabase)

    Backups, replicas and pooling handled for you; Supabase also exposes RLS directly to clients.

  • SQLite

    Embedded, zero-ops database; ideal for local, offline or edge use cases.

Best practices

  1. Let the database enforce rules with constraints, including CHECK and EXCLUDE.
  2. Use timestamptz for instants and NUMERIC or bigint minor units for money.
  3. Use JSONB for variable data only, with a GIN index when you query it.
  4. Create indexes CONCURRENTLY on live tables and set lock timeouts on migrations.
  5. Keep autovacuum healthy and avoid long-running transactions.
  6. Use a connection pooler and keep tenant settings transaction-local.
  7. Run prisma migrate deploy in production and review generated SQL.

Common mistakes

  • Putting core relational data into one big JSONB column.
  • Assuming RLS protects you while connecting as the table owner or a superuser.
  • Running prisma migrate dev or db push against production.
  • Ignoring bloat and wraparound warnings from autovacuum.
  • Opening hundreds of direct connections from serverless functions without pooling.

Deep dive

08Reading plans: EXPLAIN, statistics and estimates

The planner chooses a plan from estimated row counts, and most bad plans come from bad estimates. EXPLAIN (ANALYZE, BUFFERS) runs the query and shows, for each node, estimated rows versus actual rows, time, and how many pages came from shared buffers (hit) versus disk (read); since PostgreSQL 18, BUFFERS is included automatically with ANALYZE. When estimates are off by ten times or more, the planner may pick a nested loop where a hash join was needed. Estimates come from statistics gathered by ANALYZE, which autovacuum runs, but they assume columns are independent. For correlated columns, such as city and postal code or tenant and status, create extended statistics so the planner knows the relationship. For skewed columns, raise the statistics target on that column. Prepared statements add a twist: after five executions Postgres may switch to a generic plan that ignores parameter values, which is bad when one tenant has millions of rows and others have ten; plan_cache_mode lets you control that.

sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE company_id = $1 AND status = 'open';
 
-- planner thinks company and status are independent; teach it otherwise
CREATE STATISTICS st_orders_company_status (dependencies, mcv)
  ON company_id, status FROM orders;
ALTER TABLE orders ALTER COLUMN company_id SET STATISTICS 1000;
ANALYZE orders;
 
-- top queries by total time (needs pg_stat_statements)
SELECT calls, round(total_exec_time) AS total_ms, round(mean_exec_time, 1) AS mean_ms, query
FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 10;

09Locks and zero-downtime DDL

Most ALTER TABLE forms take an ACCESS EXCLUSIVE lock, which conflicts with everything, including plain SELECTs. The lock itself may only be held for milliseconds, but the queue is the real danger: if a long transaction holds any lock on the table, the ALTER waits, and every query that arrives after it queues behind the ALTER. The application freezes, though nothing is technically slow. The fix is to set lock_timeout to a few seconds in migrations and retry, so a blocked ALTER gives up instead of stopping traffic. Know which operations are cheap. Adding a column with a constant default is metadata-only since PostgreSQL 11. Adding a foreign key or CHECK constraint can be split: add it NOT VALID, which is quick, then VALIDATE CONSTRAINT, which scans the table with a weaker lock that allows reads and writes. SET NOT NULL can skip its table scan if a validated CHECK (col IS NOT NULL) already exists. Changing a column type usually rewrites the table, so prefer a new column and a batched backfill.

sql
SET lock_timeout = '5s';
 
-- foreign key without blocking writes for the whole scan
ALTER TABLE orders ADD CONSTRAINT fk_orders_customer
  FOREIGN KEY (customer_id) REFERENCES customers (id) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT fk_orders_customer;
 
-- NOT NULL without a long exclusive scan
ALTER TABLE orders ADD CONSTRAINT orders_currency_nn CHECK (currency IS NOT NULL) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT orders_currency_nn;
ALTER TABLE orders ALTER COLUMN currency SET NOT NULL;   -- uses the check, no scan
ALTER TABLE orders DROP CONSTRAINT orders_currency_nn;

10Connection pooling modes and their side effects

Every PostgreSQL connection is a server process with its own memory, and memory for sorts and hashes (work_mem) is allocated per operation, so a single complex query can use several multiples of it. Hundreds of direct connections therefore waste memory and add contention; a few dozen active connections are usually optimal. PgBouncer in transaction mode lets thousands of clients share a small pool by assigning a server connection only for the duration of a transaction. That breaks anything that relies on session state between transactions: SET without LOCAL, session-level advisory locks, LISTEN and NOTIFY, temporary tables and, in older PgBouncer versions, server-side prepared statements (version 1.21 added support for protocol-level prepared statements through max_prepared_statements). This is why tenant settings for row-level security must be transaction-local. Run schema migrations over a direct connection, not through the transaction pooler, because migration tools, including Prisma Migrate, rely on session-level advisory locks. Serverless platforms make pooling mandatory, since every function instance opens its own connections.

  • Size the pool from cores and storage speed, not from the number of app instances.
  • Use set_config(name, value, true) or SET LOCAL inside transactions with a transaction pooler.
  • Keep a separate direct connection string for migrations and maintenance.

11Replication, failover and major upgrades

Streaming replication ships the write-ahead log (WAL) to standbys that replay it, giving a byte-identical copy usable for reads and failover. With synchronous_standby_names and synchronous_commit you can require that a standby confirms each commit, trading latency for zero data loss on failover. Postgres itself does not elect a new primary; tools such as Patroni, or a managed service, handle health checks, promotion and fencing. Logical replication sends row changes per table instead of WAL bytes, so it works across major versions and can copy selected tables, which makes it the standard path for near-zero-downtime upgrades and migrations; it does not replicate DDL or sequence values, so you handle those yourself. Replication slots make the primary keep WAL until a consumer has received it; an abandoned slot makes the disk fill up, so set max_slot_wal_keep_size and alert on inactive slots. For in-place upgrades, pg_upgrade --link takes minutes even for large databases, and since PostgreSQL 18 it keeps planner statistics, so performance is normal right after the upgrade.

sql
-- is any slot holding back WAL?
SELECT slot_name, slot_type, active,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal
FROM pg_replication_slots;
 
-- replication lag per standby (run on the primary)
SELECT application_name, state, replay_lag FROM pg_stat_replication;

Real-world scenarios

01

A two-second migration froze the app for two minutes

Problem

A migration added a column with ALTER TABLE orders. It normally takes milliseconds, but this time every request touching orders hung until the deploy timed out.

How to solve it

The ALTER waited for an ACCESS EXCLUSIVE lock behind a long-running transaction, and all later queries queued behind the ALTER. Confirm with pg_stat_activity and pg_blocking_pids(): look for sessions in 'idle in transaction' or a long report query. Recover by cancelling the blocker with pg_cancel_backend() or terminating it with pg_terminate_backend(). Prevent it next time: run migrations with SET lock_timeout = '5s' and retry with backoff, set idle_in_transaction_session_timeout so forgotten sessions close themselves, keep reporting queries on a replica, and alert on transactions older than a few minutes. Split risky changes into NOT VALID plus VALIDATE steps so long scans never hold strong locks.

02

The primary's disk is filling up for no obvious reason

Problem

Disk usage on the primary grows by gigabytes per day, though table sizes are stable. The pg_wal directory is huge.

How to solve it

Check pg_replication_slots for an inactive slot: a replica that was removed, or a change-data-capture connector that stopped, leaves a slot that forces the primary to keep every WAL segment since. If the consumer is gone for good, drop it with pg_drop_replication_slot(). Also check that WAL archiving is succeeding, since failed archive_command runs keep WAL too; pg_stat_archiver shows failures. Then set max_slot_wal_keep_size so a stuck slot is invalidated before the disk fills, and add alerts for inactive slots and disk usage above 80 percent. If WAL is fine, look at table and index bloat from long transactions blocking vacuum, using pg_stat_user_tables dead tuple counts.

03

A query is fast in staging and slow in production

Problem

A tenant dashboard query takes 20 ms in staging but 4 seconds for one large customer in production. Same schema, same indexes.

How to solve it

Compare EXPLAIN (ANALYZE, BUFFERS) for that specific tenant in production with staging. Typical findings: row estimates far off because company_id and status are correlated, or the data is skewed so one tenant owns most rows; and a prepared statement that switched to a generic plan tuned for small tenants. Fix the estimates with CREATE STATISTICS on the correlated columns, a higher statistics target for the tenant column and a fresh ANALYZE. If generic plans are the problem, set plan_cache_mode = force_custom_plan for that role or query path. Add a composite index starting with company_id that matches the filter and sort. Keep auto_explain enabled for slow queries so future cases come with plans already logged.

Cheat sheet

Top queries by total time
SELECT calls, total_exec_time, query FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 10;
Real plan with I/O details
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;
Make DDL give up instead of queueing
SET lock_timeout = '5s';
Who is blocked and by whom
SELECT pid, pg_blocking_pids(pid), state, query FROM pg_stat_activity WHERE cardinality(pg_blocking_pids(pid)) > 0;
Stop a query or a session
SELECT pg_cancel_backend(pid); / SELECT pg_terminate_backend(pid);
Add a constraint without a long lock
ALTER TABLE t ADD CONSTRAINT c ... NOT VALID; ALTER TABLE t VALIDATE CONSTRAINT c;
Teach the planner about correlated columns
CREATE STATISTICS s ON city, zip FROM addresses; ANALYZE addresses;
Largest tables including indexes
SELECT relname, pg_size_pretty(pg_total_relation_size(relid)) FROM pg_stat_user_tables ORDER BY pg_total_relation_size(relid) DESC LIMIT 10;
Close forgotten open transactions
ALTER ROLE app SET idle_in_transaction_session_timeout = '60s';
Cap WAL kept by replication slots
max_slot_wal_keep_size = '50GB'

In my real projects

  • I co-led a multi-tenant restaurant POS SaaS built on NestJS, Prisma 6 and PostgreSQL, with 181 Prisma models in a schema of about 4,800 lines.
  • In that system, tenant isolation is enforced twice: an explicit company id in every query plus a Prisma extension that throws if the scope is missing, with allow-listed exceptions and a 'wrong tenant' test case in every module test.
  • The SaaS runs on a dedicated server with Docker Compose, PostgreSQL, Redis and BullMQ workers, with backup scripts and runbooks alongside the observability stack.
  • Its offline branch hub uses a local SQLite database with versioned migrations pinned by checksum, so branches keep selling when the internet drops.

Questions & answers

Q1Why would you choose PostgreSQL over MySQL?

For complex domains I value its correctness and expressiveness. Constraints are richer, including CHECK, deferrable foreign keys and exclusion constraints for things like overlapping bookings. DDL is transactional, so failed migrations roll back cleanly. JSONB with GIN indexes handles flexible data well, and CTEs, window functions and upserts make complex queries clean. Row-level security, partial and expression indexes, and extensions like PostGIS, pg_trgm and pgvector add capabilities without another system. MySQL is still excellent for simpler web workloads and has wider shared-hosting support, so for a standard Laravel site on existing MySQL infrastructure I would not switch just for the sake of it.

Q2Explain MVCC in PostgreSQL and why VACUUM matters.

MVCC means each transaction sees a consistent snapshot of the data without locking readers. Postgres achieves this by keeping multiple versions of a row: an UPDATE inserts a new version and marks the old one as dead, and visibility is decided by transaction ids stored on each version. The result is that reads never block writes. The cost is that dead versions remain on disk until VACUUM reclaims them. Autovacuum does this automatically, and also freezes old rows to prevent transaction ID wraparound. If it falls behind, often because of long-running or idle-in-transaction sessions, tables bloat and performance drops, so I monitor it.

Q3When do you use a GIN index instead of a B-tree?

When the value contains many searchable parts. A B-tree indexes a column's whole value in sorted order, which is perfect for equality, ranges and sorting on scalars like ids, dates and emails. A GIN index is inverted: it maps each element inside a value to the rows that contain it, so it suits JSONB containment queries with @>, array overlap, full-text search on tsvector, and trigram fuzzy search with pg_trgm. GIN indexes are larger and slower to update, so I only add them to columns that are actually queried that way, and for JSONB I often use jsonb_path_ops, which is smaller when only containment is needed.

Q4How does row-level security work, and what are the pitfalls?

You enable RLS on a table and define policies with USING for which rows can be read and WITH CHECK for which rows can be written. Policies usually compare a tenant column to a session setting that the application sets per transaction. Pitfalls: superusers and roles with BYPASSRLS ignore policies, and table owners do too unless FORCE ROW LEVEL SECURITY is set, so the app must connect as a non-owner role. With poolers like PgBouncer, settings must be transaction-local, otherwise one tenant's setting can leak to the next request. Policies add a filter to every query, so the tenant column needs indexes. I treat RLS as defence in depth, not a replacement for app-level checks.

Q5How do you handle Prisma migrations safely in production?

Migrations are created in development with prisma migrate dev, reviewed as SQL in the pull request and committed. Production and staging only ever run prisma migrate deploy, usually as a release step before new code starts, and it applies pending migrations without resetting anything. For risky changes I use --create-only and edit the SQL: add columns as nullable, backfill in batches, create indexes concurrently, which must run outside a transaction, and add constraints Prisma cannot express, like RLS policies or partial unique indexes. I never use db push or migrate reset outside local development, and destructive changes ship in a later release than the code that stops using the column.

Q6How do you back up PostgreSQL?

It depends on size and recovery goals. For small and medium databases, a nightly pg_dump in custom format is simple and lets me restore single tables with pg_restore. For production systems that need point-in-time recovery, I use a physical base backup plus continuous WAL archiving, managed by a tool such as pgBackRest or WAL-G, which handles compression, encryption, retention and off-site storage. Managed services do this for you. Whatever the method, backups must live off the database server, and I schedule regular test restores into a separate instance and measure how long they take, because that is the recovery time the business will really get.

Senior-level questions

S1How would you upgrade a busy PostgreSQL database to a new major version with minimal downtime?

There are two main paths. pg_upgrade with --link is simple and fast, usually minutes, because it reuses the data files, but the database is down during the run and rollback after starting the new cluster means restoring from backup, so I take a fresh backup and rehearse on a copy first. Since PostgreSQL 18, planner statistics survive the upgrade, which removes the slow period that used to follow. For near-zero downtime I use logical replication: build a new cluster on the new version, copy the schema, subscribe to all tables, let it catch up, then switch the application during a short write pause. The trade-offs are more work and gaps to handle yourself: DDL is not replicated, sequences must be advanced manually before cut-over, and tables need primary keys or a replica identity. Managed services often wrap the second approach as blue-green deployments.

S2Postgres can be a queue, a search engine and a vector store. When should it not be?

Using Postgres for adjacent jobs is often the right call because it removes a system to run and keeps transactions in one place. A job queue with SELECT ... FOR UPDATE SKIP LOCKED works well up to thousands of jobs per second, and the job is enqueued atomically with the business change. Full-text search with tsvector and pg_trgm covers site search with filters; I move to a dedicated engine when I need typo tolerance, relevance tuning, many facets or many languages at scale. pgvector handles millions of embeddings with HNSW indexes, especially when vectors must be filtered by tenant or permissions. Dedicated tools win when a workload's scale or features dominate: very high-throughput event streams, analytics over billions of rows (columnar stores), or search as a core product. The test is whether the extra workload starts to hurt the primary's main job, transactional reliability.

S3How do you design PostgreSQL for a high write rate, such as event or telemetry ingestion?

I reduce work per row first. Batch inserts with multi-row INSERT or COPY instead of one transaction per event. Keep indexes minimal on the hot table, since every index multiplies write cost; a BRIN index on the timestamp is tiny and fits append-only data. Partition by time so indexes stay small and retention is a cheap partition drop instead of DELETE and vacuum. For tables with frequent updates, a lower fillfactor leaves room for HOT updates, which avoid touching indexes. Tune checkpoints, with a larger max_wal_size, so the server does not flush constantly. For data that can tolerate losing the last moments in a crash, synchronous_commit = off per session gives a big speed-up without risking corruption; unlogged tables go further but are emptied after a crash. If raw volume keeps growing, I aggregate into rollup tables or move analytics to a columnar store.

Glossary

WAL
Write-ahead log: every change is written here first, used for crash recovery, replication and point-in-time restore.
ACCESS EXCLUSIVE lock
The strongest table lock; it blocks all reads and writes and is taken by most ALTER TABLE forms.
Lock queue
Waiting lock requests in order; a waiting ALTER makes every later query on that table wait too.
NOT VALID constraint
A constraint enforced for new rows only, validated later with a weaker lock that allows traffic.
Extended statistics
Planner statistics across several columns, created with CREATE STATISTICS, to fix estimates for correlated data.
Generic plan
A cached plan for a prepared statement that ignores actual parameter values; can be poor for skewed data.
Replication slot
A marker that makes the primary keep WAL until a specific consumer has received it.
Logical replication
Replicating row changes per table rather than WAL bytes; works across major versions.
Patroni
An open-source tool that manages PostgreSQL high availability: leader election, failover and replica configuration.
pg_upgrade
The tool for in-place major-version upgrades; with --link it reuses data files and finishes quickly.

Have a product in mind?Let's build it.

Tell me about your idea, your product or your team. I usually reply within one working day.