A schema is a contract: every row of this table looks like this, every relationship means that. Get it right and reads are fast, invariants hold, and changes stay cheap. Get it wrong and you'll fight the same bugs for years.
← Back to Database SideNOT NULL, UNIQUE, CHECK, foreign key.Start with nouns and verbs. List the entities (User, Link, Click), the relationships (a User owns many Links; a Link has many Clicks), and the operations (create a Link, resolve a code, aggregate Clicks per day). The schema is a translation of that model — not the other way around.
If you can't describe what each table represents in one short sentence, the model isn't ready yet.
| Key style | Pros | Cons |
|---|---|---|
| Auto-increment integer | Tiny, fast, simple. | Leaks volume; awkward across shards; guessable in URLs. |
| UUID v4 (random) | Unique anywhere, generate client-side. | Bigger; random inserts hurt B-tree locality. |
| UUID v7 / ULID | Time-ordered + globally unique. Best of both. | Newer; not all drivers support natively. |
| Natural key (email, ISBN) | Meaningful; avoids one extra join. | Mutable in practice. Avoid as primary key. |
Rule of thumb: synthetic keys for primary, natural keys for UNIQUE indexes. Never let a primary key carry meaning that might change.
Practically: if you find yourself updating the same fact in two rows, it's not in 3NF.
Denormalize when a join is on the read path of your hottest query and the data being duplicated changes rarely. Examples: storing order.customer_name as it was at purchase time, caching post.comment_count, materializing a daily rollup.
The cost is write complexity: every change must update every copy. Pay it deliberately, not by accident.
NOT NULL on every column that must have a value. NULL means "unknown" — only use it when that's actually what you mean.UNIQUE on natural identifiers (email, slug, short code).ON DELETE behavior — CASCADE, SET NULL, or RESTRICT. Pick one consciously.CHECK constraints for invariants — price > 0, status IN ('active','expired').Application code is one of many writers. Constraints in the database are the only ones that hold no matter who is writing.
EXPLAIN plans, not your intuition.(user_id, created_at) serves WHERE user_id = ? and WHERE user_id = ? ORDER BY created_at, but not WHERE created_at > ? alone.CREATE INDEX … WHERE deleted_at IS NULL.users, link_clicks). Or singular — pick one, stay consistent.url, id).<referenced_table_singular>_id (user_id, link_id).is_active, not is_not_disabled.created_at, updated_at, deleted_at. Always store UTC; convert at the edge.idx_<table>_<cols>. Constraints: chk_, uq_, fk_ prefixes.Production schemas change. The question isn't whether, but how safely.
VARCHAR for everything. Reach for the right type — TIMESTAMPTZ, UUID, NUMERIC, JSONB, BOOLEAN, enums.FLOAT. Use NUMERIC(precision, scale) or store integer minor units.WHERE deleted_at IS NULL. Forget once and data leaks.created_at and updated_at on every table. You'll thank yourself during your first incident.tenant_id everywhere) or schema-per-tenant before you ship.A reasonable starting point — small, normalized, with the right constraints:
CREATE TABLE users ( id BIGSERIAL PRIMARY KEY, email TEXT NOT NULL, password_hash TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT uq_users_email UNIQUE (email) ); CREATE TABLE links ( id BIGSERIAL PRIMARY KEY, code TEXT NOT NULL, target_url TEXT NOT NULL, owner_id BIGINT REFERENCES users(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), expires_at TIMESTAMPTZ, deleted_at TIMESTAMPTZ, CONSTRAINT uq_links_code UNIQUE (code), CONSTRAINT chk_links_url CHECK (target_url ~* '^https?://') ); CREATE INDEX idx_links_owner_active ON links (owner_id) WHERE deleted_at IS NULL; CREATE TABLE clicks ( id BIGSERIAL PRIMARY KEY, link_id BIGINT NOT NULL REFERENCES links(id) ON DELETE CASCADE, occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(), referrer TEXT, user_agent TEXT, country TEXT ); CREATE INDEX idx_clicks_link_time ON clicks (link_id, occurred_at DESC);
Notice: every table has timestamps; every UNIQUE business field has a constraint; the partial index on active links keeps it small; the clicks index is ordered for the most common query (recent clicks for a link).