Database design is one of those areas where the cool patterns from conference talks rarely apply to your actual problem. Most production database issues are boring: missing indexes, badly-shaped queries, schemas that didn't anticipate access patterns. This guide is the practical 2026 playbook — the patterns that work, the ones that get over-applied, and the trade-offs you'll meet at every junction.
What changed in 2026
- Postgres 17 is the most common production version, with better partitioning, improved logical replication, and SQL/JSON improvements.
- Serverless Postgres (Neon, Supabase, Xata) is now a first-class production option for many workloads.
- Vector columns (pgvector) became standard for embedding-based search — no separate vector DB needed for many use cases.
Indexing — the 80% of performance
Most slow queries lack an index, or have an index the planner refuses to use. The patterns that pay:
- Indexes on every foreign key. Postgres doesn't create them automatically; most ORMs do but verify.
- Composite indexes for multi-column WHERE/ORDER BY combinations. Order matters: most-selective column first, but for ORDER BY, match the query's column order.
- Partial indexes for soft-delete and tenant filtering.
WHERE deleted_at IS NULL or WHERE org_id = $1 shrink the index and speed queries.
- Covering indexes (INCLUDE clause) for index-only scans on hot read paths.
- Avoid indexing low-cardinality columns alone. A boolean column index is rarely useful.
EXPLAIN ANALYZE is your best friend. Read it weekly on your slowest queries.
Normalization vs denormalization
The rule that almost works: normalize until proven otherwise.
Denormalize when:
- Reads dramatically dominate writes (10:1+).
- Joins consistently appear in p95 latency reports.
- Eventual consistency is acceptable for the denormalized fields.
Common denormalization patterns:
- Counter caches. Store
post.comments_count instead of joining and counting on every read.
- Embedded copies. Store username at message-creation time; reflect renames lazily or not at all.
- Aggregated tables. Materialized daily/hourly rollups for dashboards.
The cost is consistency complexity. Use database triggers, application-level updates, or async workers — pick one and stick to it per relationship.
Soft delete — use carefully
Soft delete (deleted_at timestamp instead of DELETE) is useful for:
- Accidental-delete recovery.
- Audit and history.
- Compliance.
It's costly because:
- Every query needs
WHERE deleted_at IS NULL.
- Foreign keys to "deleted" rows are weird.
- Unique constraints break (two rows can have the same email if one is "deleted").
If you do soft-delete, partial unique indexes (WHERE deleted_at IS NULL) and consistent query filtering (often via ORM scopes) are mandatory. Consider a separate tombstones table or archive process for high-volume tables.
Audit trails
Three approaches:
1. Audit table per business table. orders_audit mirrors orders with changed_at, changed_by, old_values, new_values. Triggered on UPDATE/DELETE.
2. Generic event log. Single events table; rows describe what changed. Flexible but harder to query.
3. Temporal tables / system-versioning. Built into SQL Server, available via extensions in Postgres. Cleanest but vendor-specific.
For most apps, approach #1 with database triggers is the sweet spot — easy to query, easy to retain, easy to ignore when you don't need it.
ID strategy
| Strategy |
When to use |
| Auto-increment integer |
Single-database, internal IDs |
| UUIDv4 |
Distributed systems, public IDs, security |
| UUIDv7 (timestamp-prefixed) |
Distributed + sortable, best of both |
| Snowflake-style |
Custom distributed systems |
| ULID |
Sortable, URL-friendly |
In 2026, UUIDv7 is the modern default for new schemas — sortable like integers, distributed-safe like UUIDs. Postgres has good support via gen_uuid_v7() extensions.
Migrations that don't break production
- Additive first. Add columns nullable, then backfill, then make NOT NULL.
- Renames in three steps. Add new column, dual-write, drop old.
- Index creation CONCURRENTLY in Postgres for tables with active traffic.
- Schema changes off-peak when possible.
- Feature-flag schema use in app code to decouple deploy from schema change.
The two-deploy migration is annoying but bulletproof: deploy code that can read both old and new schema → migrate → deploy code that uses only new.
What to skip
- Premature sharding. A single Postgres instance comfortably handles billions of rows. Most teams that shard early regret it.
- Premature partitioning. Same story. Use it when a table exceeds tens of millions of rows AND has clear partition keys.
- NoSQL because "we'll scale". Postgres scales further than you think. Pick NoSQL for actual schema/access reasons, not "scale".
- Multi-tenant schemas with separate databases per tenant unless compliance requires. Operational overhead is huge.
Concurrency patterns
- Row-level locking (
SELECT ... FOR UPDATE) for stock counters, balance updates.
- Advisory locks for job queues, distributed mutexes.
- Optimistic locking (
WHERE version = ?) for low-contention writes.
- Postgres SKIP LOCKED for high-throughput work queues — multiple workers pull work without blocking.
FAQ
Postgres vs MySQL in 2026?
Postgres for new projects, by a wide margin. MySQL still solid for existing apps; not the default choice for greenfield.
ORM or raw SQL?
ORM for 80% of CRUD; raw SQL for performance-critical or complex queries. Most modern ORMs (Drizzle, Prisma, sqlx) make this easy.
JSONB column or separate table?
JSONB when schema is genuinely variable or rarely queried. Separate table when you regularly query/index the fields.
How big until I need read replicas?
Most apps don't need them. Add when CPU on the primary is consistently high or you have specific read-heavy workloads.
Where to go next
For related material see Drizzle vs Prisma in 2026, Neon vs Supabase in 2026, and How to deploy a full-stack app in 2026.