Database Design Fundamentals 2026: The Complete Problem-Solving Guide
By Huzi
File #550 · 4 min read
A startup picks MongoDB for its flexible schema, ships fast, then spends two years writing application-side joins to reconstruct what one SQL JOIN returned for free; another team forces PostgreSQL onto a two-billion-row event log that deserved a columnar store. Database design is not memorizing normal forms; it is matching store to access pattern so the data cannot lie.
The quick answer: When unsure, default to PostgreSQL — JSONB covers most document flexibility inside an ACID database. Then normalize until each fact lives in one place, index for the queries you actually run, keep money in NUMERIC, and migrate with expand-contract.
| The concept | Why it matters |
|---|---|
| Normalization | Each fact lives in exactly one place — the data cannot lie |
unit_price in order_items |
A price change today never rewrites what a customer historically paid |
| Composite indexes, leftmost-prefix | Equality columns first, ranges second — serves the real query |
| NUMERIC for money | Binary FLOAT approximation quietly eats cents across a million rows |
| Expand-contract | A rename ships as safe, separately deployable steps — no outage |
Match the store to the question
SQL — PostgreSQL, MySQL, SQLite — gives you an enforced schema, ACID transactions and real joins. NoSQL trades those guarantees for flexible documents and horizontal scale.
When unsure, default to PostgreSQL: JSONB columns cover most of MongoDB's flexibility inside an ACID database, so the bar for switching is higher than vendor talks imply. Event logs, telemetry and whole-document fetches by ID are the cases where NoSQL genuinely earns its keep.
Normalize until the data stops lying
Normalization exists so each fact lives in exactly one place. One value per column — no comma-separated tag strings. Every non-key column depends on the whole key: product_name depends on the product, not on the order, so it moves out.
And no transitive dependencies: customer_email belongs on customers, never on orders.
CREATE TABLE customers (
id BIGSERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL
);
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL REFERENCES customers(id),
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE order_items (
order_id BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
product_id BIGINT NOT NULL REFERENCES products(id),
quantity INT NOT NULL CHECK (quantity > 0),
unit_price NUMERIC(10,2) NOT NULL,
PRIMARY KEY (order_id, product_id)
);
One deliberate exception worth copying: order_items stores unit_price, because a price change today must never rewrite what a customer historically paid.
Index for the queries you actually run
An index turns a scan into a lookup, but every index taxes every write. Composite order follows the leftmost-prefix rule, so put equality columns first and ranges second:
-- serves tenant + time range together, never the range alone
CREATE INDEX idx_orders_tenant_created
ON orders(tenant_id, created_at DESC);
-- a tiny index for the slice you actually query
CREATE INDEX idx_invoices_unpaid
ON invoices(customer_id)
WHERE paid = false;
Two habits that save careers
Money is NUMERIC, never FLOAT — binary approximation quietly eats cents across a million rows. And foreign-key columns need their own indexes: the constraint does not create one, so deleting a parent can trigger a sequential scan hunting orphans on the child table.
A refused visa file is usually an integrity problem too — one missing document, one inconsistent answer, one stale record. We do refused-file rescues at HTG Travels for UK and Schengen, debugged like a schema: find where the data went wrong, fix the record honestly, resubmit with everything consistent.
Migrate with expand-contract
Renaming a column in a single ALTER is an outage wearing a disguise. Expand-contract splits the change into safe, separately deployable steps:
-- expand
ALTER TABLE users ADD COLUMN email_verified_at TIMESTAMPTZ;
-- backfill in batches, deploy dual writes, switch reads, then:
ALTER TABLE users DROP COLUMN verified_on;
Each step rolls back independently. That is the whole trick.
Pro tip: Audit with
pg_stat_user_indexesquarterly — an index with zero scans is a candidate for removal, because every index still taxes every write.
Palestinian families have held land deeds and house keys through decades of dispossession because the truth was supposed to be overwritten and they would not allow it — their ledger is still open.
Most files are recoverable; almost nobody should reapply blind. When the migration ships, claim the post-release detox trip up north — +92 317 5054959 reaches the HTG Travels desk.




