Designing Scalable Database Schemas for Web Applications
A database schema is one of the hardest things to change once an application is in production. This article covers practical principles for designing schemas that stay fast, consistent, and maintainable as your application and data grow.
Harrison Onyango Aloo
Backend Software Engineer — Node.js · Python · Payment Integrations
It's easy to treat schema design as a first-week decision you never revisit. In reality, your schema is one of the most expensive things to change once real data and real traffic depend on it. Application code can be refactored in an afternoon; a poorly designed table with millions of rows often requires a careful, multi-step migration just to fix.
Designing for scale doesn't mean over-engineering for traffic you don't have yet. It means making decisions early that won't actively work against you later — proper normalization, sensible keys, thoughtful indexing, and a clear plan for how the schema will evolve.
1. Start with Normalization, Then Know When to Denormalize
Normalization reduces data duplication and keeps your data consistent. A classic mistake is storing repeated information — like a customer's name and email — directly on every order row instead of referencing a customers table.
-- Avoid: duplicated customer data on every order
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
customer_name TEXT,
customer_email TEXT,
total NUMERIC(10, 2)
);
-- Prefer: normalized reference
CREATE TABLE customers (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
);
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers(id),
total NUMERIC(10, 2) NOT NULL
);
That said, strict normalization isn't always the right call at scale. Denormalizing a frequently-read, rarely-changed value (like caching an order_count on the customers table) can save expensive joins and aggregations on read-heavy paths. The rule of thumb: normalize by default, denormalize deliberately, and document why when you do.
2. Choose the Right Primary Keys
Auto-incrementing integers are simple and index-friendly, but they leak information (competitors can estimate your order volume) and don't work well across distributed systems. UUIDs solve both problems but are larger and can hurt index locality if used carelessly.
-- Sequential integer: fast, simple, but predictable
CREATE TABLE orders (
id SERIAL PRIMARY KEY
);
-- UUID: safe to generate client-side, works across services
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid()
);
If you expect to shard data across multiple databases eventually, plan your primary key strategy now. Retrofitting UUIDs onto a system built around auto-increment IDs is painful.
3. Index Strategically, Not Excessively
Indexes speed up reads but slow down writes and consume storage. Index columns that are actually used in WHERE, JOIN, and ORDER BY clauses — not every column that seems important.
-- Index the foreign key used in joins
CREATE INDEX idx_orders_customer_id ON orders(customer_id);
-- Composite index for a common query pattern
CREATE INDEX idx_orders_customer_status
ON orders(customer_id, status)
WHERE status != 'completed';
A few habits worth building:
- Always index foreign key columns; most databases don't do this automatically.
- Use
EXPLAIN ANALYZEto confirm an index is actually being used before assuming it helps. - Avoid indexing low-cardinality columns (like a boolean) on their own — the index often isn't selective enough to be worth it.
- Periodically audit for unused indexes; they still cost you on every write.
4. Model Relationships Carefully
Most schema bugs come from getting relationships wrong. A few patterns come up constantly:
One-to-many
CREATE TABLE authors (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE books (
id SERIAL PRIMARY KEY,
author_id INTEGER NOT NULL REFERENCES authors(id),
title TEXT NOT NULL
);
Many-to-many, via a join table
CREATE TABLE students (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE courses (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL
);
CREATE TABLE enrollments (
student_id INTEGER NOT NULL REFERENCES students(id),
course_id INTEGER NOT NULL REFERENCES courses(id),
enrolled_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (student_id, course_id)
);
The join table isn't just a technical requirement — it's often where important business data lives (like enrolled_at above). Resist the urge to store many-to-many relationships as comma-separated IDs in a text column; it looks like a shortcut but breaks referential integrity and makes queries painful.
5. Plan for Growth: Partitioning and Sharding
A single, unpartitioned table can serve an application for years — until it can't. When a table grows into the tens or hundreds of millions of rows, query performance and maintenance operations (like VACUUM or index rebuilds) start to degrade.
Partitioning splits one logical table into smaller physical pieces, transparently to most queries:
CREATE TABLE events (
id BIGSERIAL,
created_at TIMESTAMPTZ NOT NULL,
payload JSONB
) PARTITION BY RANGE (created_at);
CREATE TABLE events_2026_01 PARTITION OF events
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE events_2026_02 PARTITION OF events
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
Sharding goes a step further, splitting data across separate database instances entirely — typically by a key like customer_id or region. It solves scale problems partitioning can't, but introduces real complexity around cross-shard queries and joins. Don't reach for sharding until partitioning, indexing, and read replicas are no longer enough.
6. Avoid the N+1 Query Problem
A well-designed schema can still perform badly if the application queries it inefficiently. The N+1 problem happens when fetching a list triggers one additional query per row.
// N+1: one query per author (bad)
const books = await db.book.findMany();
for (const book of books) {
const author = await db.author.findUnique({ where: { id: book.authorId } });
}
// Fixed: a single query with a join
const books = await db.book.findMany({
include: { author: true }
});
This isn't strictly a schema issue, but schema design and query patterns are tightly linked — a schema that requires excessive joins to answer common questions is a sign it may need to be revisited.
7. Use Constraints to Enforce Data Integrity
Don't rely on application code alone to keep data valid. Constraints at the database level are the last line of defense, and they catch bugs that application-level validation misses (like a race condition between two requests).
CREATE TABLE accounts (
id SERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
balance NUMERIC(12, 2) NOT NULL CHECK (balance >= 0),
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'suspended', 'closed'))
);
NOT NULL, UNIQUE, CHECK, and foreign key constraints are cheap insurance against bad data that's far more expensive to clean up after the fact.
8. Soft Deletes vs. Hard Deletes
Deciding how records are removed affects your schema from day one. Soft deletes (a deleted_at timestamp) preserve history and simplify recovery, but every query in the application now needs to filter it out — and unique constraints get trickier.
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email TEXT NOT NULL,
deleted_at TIMESTAMPTZ,
UNIQUE (email) -- problem: can't reuse an email after "deletion"
);
-- Better: partial unique index that ignores soft-deleted rows
CREATE UNIQUE INDEX idx_users_email_active
ON users(email)
WHERE deleted_at IS NULL;
Pick soft deletes when you need an audit trail or undo capability; pick hard deletes when simplicity and storage matter more. Mixing both approaches inconsistently across a schema is a common source of confusion later.
9. Version Your Schema with Migrations
Never hand-edit a production schema. Every change should go through a migration file that's reviewed, versioned, and reversible.
-- 20260812_add_status_to_orders.sql
ALTER TABLE orders
ADD COLUMN status TEXT NOT NULL DEFAULT 'pending';
CREATE INDEX idx_orders_status ON orders(status);
Tools like Prisma Migrate, Flyway, or plain SQL migration files under version control all work — what matters is that schema changes are tracked the same way code changes are, with a clear history of what changed and why.
10. Plan for Reads Early: Replicas and Caching
Most web applications are read-heavy. Before reaching for a bigger database instance, consider:
- Read replicas to offload reporting and analytics queries from your primary database.
- Caching (Redis, an in-memory layer, or materialized views) for expensive, frequently-repeated queries.
- Materialized views for aggregations that don't need to be real-time.
CREATE MATERIALIZED VIEW daily_revenue AS
SELECT
DATE(created_at) AS day,
SUM(total) AS revenue
FROM orders
GROUP BY DATE(created_at);
-- Refresh on a schedule, not on every request
REFRESH MATERIALIZED VIEW daily_revenue;
Actionable Takeaways
If you're auditing an existing schema this week, start here:
Run EXPLAIN ANALYZE on your five slowest queries and check whether the right indexes exist.
Confirm every foreign key column has a matching index.
Check for unbounded tables (like events or logs) that will eventually need partitioning.
Review any comma-separated ID columns and replace them with proper join tables.
Make sure schema changes go through migrations, not manual edits in production.
Final Thoughts
Scalable schema design isn't about predicting every future requirement — it's about avoiding decisions that are expensive to undo. Normalize thoughtfully, index deliberately, enforce integrity at the database level, and treat your schema's evolution with the same discipline as your application code.
The schemas that scale well aren't necessarily the cleverest ones. They're the ones that were designed to be changed.