Filter concepts by levelShowing all levels.

PostgreSQL · Section 9

Schema Design and Data Modeling

Level
intermediate
Read
38 min
Concepts
7

Normalization as redundancy elimination and denormalization as a deliberate, measured trade, modeling cardinality and optionality as two independent questions about a relationship, surrogate vs natural keys and UUID vs integer identifiers, the common audit-field conventions and what actually keeps them accurate, soft deletes' query and uniqueness implications, and the discipline of designing for real future change without premature complexity.

PostgreSQL overview

What is true here

  1. A 3NF violation shows up as the same fact stored in multiple rows that can silently disagree.
  2. Cardinality (which table holds the foreign key) and optionality (NOT NULL or not) are independent design questions.
  3. A surrogate key is stable and meaningless by design; a natural key can change along with the real-world fact it represents.
  4. updated_at needs an active mechanism — DEFAULT only fires on INSERT, never on UPDATE.
  5. Soft deletes require every query to filter deleted_at IS NULL explicitly, and existing UNIQUE constraints need a partial-index rewrite.

What you will be able to do

  • Recognize a normalization violation and judge whether denormalizing it is actually justified
  • Model a relationship's cardinality and optionality correctly, independently of each other
  • Choose between a surrogate and natural key, and between a UUID and integer identifier, based on real requirements
  • Design audit fields that actually stay accurate, and soft deletes that do not silently break uniqueness
  • Distinguish a concrete near-term schema need from a speculative one, and design accordingly
From structure to the judgment call about future change

Normalized structure

entities, relationships, cardinality

Key choice

surrogate vs natural, UUID vs integer

Audit + soft-delete conventions

created_at/updated_at, deleted_at

Designing for change

cheap real evolution vs premature generality

  • Normalized structure — entities, relationships, cardinality
    • leads to Key choice
  • Key choice — surrogate vs natural, UUID vs integer
    • leads to Audit + soft-delete conventions
  • Audit + soft-delete conventions — created_at/updated_at, deleted_at
    • leads to Designing for change
  • Designing for change — cheap real evolution vs premature generality

Structure

Normal forms and denormalization, and modeling relationships accurately.

Normalization: 1NF, 2NF, 3NF and When Denormalization Is Justified

coreintermediate

1NF: every column holds one atomic value, no repeating groups. 2NF: every non-key column depends on the whole primary key, not part of it. 3NF: every non-key column depends only on the key, not on another non-key column. Denormalization deliberately breaks one of these for a specific, measured performance or simplicity reason.

Think of it as

Each normal form eliminates one specific way redundancy creeps in. 1NF stops you from stuffing a list into one column. 2NF stops a composite-key table from storing a fact that really only depends on part of the key (which then repeats needlessly per other-part-of-key value). 3NF stops a table from storing a fact that depends on another non-key column rather than the key itself (which then has to be updated in multiple places to stay consistent). Denormalization is the informed choice to accept one of these redundancies anyway, because the read-performance or simplicity benefit outweighs the update-anomaly risk for that specific case.

sql
-- normalized: city lives in one place, keyed by zip
CREATE TABLE zip_codes (zip TEXT PRIMARY KEY, city TEXT NOT NULL);
CREATE TABLE orders (id SERIAL PRIMARY KEY, zip TEXT REFERENCES zip_codes(zip));

What we're doing: Show a 3NF violation causing an update anomaly (two rows disagreeing about the same fact), then the normalized fix that makes the anomaly structurally impossible.

normalization_demo.sqlsql
CREATE TABLE orders_denorm (id SERIAL PRIMARY KEY, zip TEXT, city TEXT);
INSERT INTO orders_denorm (zip, city) VALUES ('10001', 'New York'), ('10001', 'New York');

UPDATE orders_denorm SET city = 'NYC' WHERE id = 1;
-- only order 1 updated -- order 2 still says 'New York' for the SAME zip code

SELECT DISTINCT zip, city FROM orders_denorm;
-- one zip code, two different city spellings -- an update anomaly, structurally possible here
2
Both orders share zip 10001, and both should always agree on the city that zip maps to.
4
Updating just one row's city leaves the schema in an inconsistent state — nothing prevents zip and city from disagreeing across rows.
7–8
The same zip code now maps to two different city spellings — a direct consequence of storing city redundantly instead of deriving it from zip via a lookup.
Output
zip   | city
-------+----------
10001 | NYC
10001 | New York
(2 rows)

Why this works: Nothing in the orders_denorm schema enforces that every row with zip = '10001' agrees on city, because city is stored independently per row rather than derived from a single authoritative source — this is exactly the transitive dependency 3NF is defined to eliminate. Moving city into its own zip_codes table keyed by zip makes the inconsistency structurally impossible: there would be exactly one row for zip 10001, and every order referencing it automatically sees the same, single value.

Denormalizing without a specific, measured reason

Wrong

sql
CREATE TABLE orders (id SERIAL PRIMARY KEY, zip TEXT, city TEXT, customer_name TEXT);
-- redundant data copied in "for convenience," with no actual performance measurement behind the decision

Better

sql
CREATE TABLE orders (id SERIAL PRIMARY KEY, zip TEXT REFERENCES zip_codes(zip), customer_id INT REFERENCES customers(id));
-- normalized by default; denormalize later, deliberately, if a specific measured read path actually needs it

What you see: A schema accumulates redundant copies of the same fact across many tables "for convenience" or "in case it's faster," with no actual measurement showing the normalized version was too slow — and now every one of those copies needs separate logic to stay in sync.

Why: Denormalization is a real, valid engineering trade-off, but it trades update simplicity and consistency for read performance — a trade that is only worth making when a specific, measured read path actually needs it. Applying it by default, everywhere, without measurement, accepts the update-anomaly risk (as demonstrated above) without ever collecting the performance benefit it was supposed to be worth.

A 3NF violation and its fix

Denormalized (3NF violation)

  • +orders stores both zip and city directly
  • +city is really determined by zip, not by the order itself
  • +updating a zip code's city means updating every order row that used it

Normalized (3NF)

  • orders stores only zip
  • a separate zip_codes table maps zip -> city, once
  • updating a city means changing exactly one row
  • Denormalized (3NF violation)
    • orders stores both zip and city directly
    • city is really determined by zip, not by the order itself
    • updating a zip code's city means updating every order row that used it
  • Normalized (3NF)
    • orders stores only zip
    • a separate zip_codes table maps zip -> city, once
    • updating a city means changing exactly one row

What each normal form eliminates

What each normal form eliminates
FormEliminates
1NFnon-atomic values / repeating groups in one column
2NFa fact depending on only part of a composite key
3NFa fact depending on a non-key column instead of the key

Together

sql
-- 3NF violation: city and zip both stored per order, but zip determines city
CREATE TABLE orders (id SERIAL PRIMARY KEY, customer_id INT, zip TEXT, city TEXT);
-- fix: store zip -> city once, in its own table

Remember: 1NF/2NF/3NF each eliminate one specific kind of redundancy; a 3NF violation shows up as the same fact stored in multiple rows that can silently disagree. Denormalize deliberately, for a specific measured reason — not by default.

See also: entities relationships cardinality · constraints prevent duplicate orphaned invalid data

Modeling Entities, Relationships, Cardinality and Optionality

standardintermediate

An entity is a distinct thing worth its own table (a customer, an order). A relationship connects two entities. Cardinality is how many of one entity can relate to how many of another (one-to-many, etc). Optionality is whether that relationship is required or may be absent (must every order have a customer? must every customer have an order?).

Think of it as

Cardinality answers "how many," optionality answers "must there be at least one." They are independent questions about the same relationship: an order must belong to exactly one customer (cardinality: one; optionality: required) — modeled as customer_id NOT NULL. A customer may have zero orders (cardinality: many; optionality: optional) — modeled as no constraint forcing an order to exist. Getting both questions right for every relationship is what a schema diagram is really encoding.

sql
-- required one-to-many
CREATE TABLE orders (id SERIAL PRIMARY KEY, customer_id INT NOT NULL REFERENCES customers(id));

-- optional one-to-many
CREATE TABLE orders (id SERIAL PRIMARY KEY, promo_code_id INT REFERENCES promo_codes(id));

What we're doing: Model two relationships from the same table that differ in optionality but not cardinality, and confirm each behaves as intended.

cardinality_optionality.sqlsql
CREATE TABLE customers (id SERIAL PRIMARY KEY);
CREATE TABLE promo_codes (id SERIAL PRIMARY KEY);
CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    customer_id INT NOT NULL REFERENCES customers(id),    -- required: every order needs one
    promo_code_id INT REFERENCES promo_codes(id)           -- optional: most orders have none
);

INSERT INTO customers DEFAULT VALUES;
INSERT INTO orders (customer_id, promo_code_id) VALUES (1, NULL);
-- succeeds: promo_code_id is optional

INSERT INTO orders (customer_id) VALUES (NULL);
-- fails: customer_id is required
4
NOT NULL encodes "required" — both relationships are one-to-many from orders' perspective, but only this one is mandatory.
5
No NOT NULL here — the same cardinality shape (many orders, one promo code), but legitimately optional.
9–10
An order with no promo code is valid — the relationship is genuinely optional.
Output
INSERT 0 1

ERROR:  null value in column "customer_id" of relation "orders" violates not-null constraint

Why this works: Both customer_id and promo_code_id are foreign keys with identical cardinality (many orders can point at one customer, or one promo code) — the only schema-level difference is NOT NULL, which is exactly where optionality lives. This is why cardinality and optionality need to be reasoned about as two separate questions: getting the cardinality right (which table holds the foreign key) does not automatically get the optionality right (whether NOT NULL belongs on it).

Modeling an optional relationship as required, or vice versa, without checking the real business rule

Wrong

sql
CREATE TABLE orders (id SERIAL PRIMARY KEY, promo_code_id INT NOT NULL REFERENCES promo_codes(id));
-- forces every order to have a promo code -- but most orders legitimately have none

Better

sql
CREATE TABLE orders (id SERIAL PRIMARY KEY, promo_code_id INT REFERENCES promo_codes(id));
-- nullable -- correctly allows an order with no promo code

What you see: Every order insertion is forced to invent or reference a placeholder promo code just to satisfy a NOT NULL constraint that does not reflect the actual business rule, polluting the promo_codes table with meaningless rows.

Why: Cardinality (how many) and optionality (whether it must exist) are independent design decisions that both need deliberate answers from the actual business rules, not defaults — assuming every foreign key should be NOT NULL, or assuming none should, skips the analysis that determines which relationships genuinely are required. The fix is asking the question directly for each relationship: can this thing legitimately exist without that other thing? If yes, the foreign key is nullable; if no, NOT NULL enforces the requirement.

Cardinality and optionality, modeled independently

Cardinality and optionality, modeled independently
RelationshipCardinalityOptionalitySchema
order → customermany orders, one customerrequired — every order needs onecustomer_id INT NOT NULL REFERENCES customers(id)
customer → ordersone customer, many ordersoptional — a customer may have noneno constraint forces an order to exist
user → profile photoone-to-oneoptional — a user may have nonephoto_url TEXT (nullable) or a separate nullable-relationship table

Together

sql
CREATE TABLE orders (id SERIAL PRIMARY KEY, customer_id INT NOT NULL REFERENCES customers(id));

Remember: Cardinality (how many) and optionality (must it exist) are independent questions about the same relationship — cardinality decides which table holds the foreign key, optionality decides whether that column is NOT NULL.

See also: normalization · referential integrity

Advertisement

Choosing keys

Surrogate vs natural keys, and UUID vs integer identifiers.

Choosing Surrogate vs Natural Keys Deliberately

standardintermediate

A natural key is a real-world attribute that already uniquely identifies a row, like an email address or an ISBN. A surrogate key is an artificial identifier with no business meaning, generated purely to identify the row, like a SERIAL id. Most modern schemas default to a surrogate primary key and add a UNIQUE constraint on the natural key separately.

Think of it as

A natural key ties the row's identity to real-world data, which means the identity changes if that real-world data changes — an email address used as a primary key means every foreign key referencing that row breaks (or needs cascading updates) if the person changes their email. A surrogate key's identity is permanent and meaningless by design, decoupling "how do I find this row" from "what does this row currently say about itself" — which is exactly the property that makes it stable as a foreign key target.

sql
CREATE TABLE users (
    id SERIAL PRIMARY KEY,          -- surrogate: stable, meaningless
    email TEXT UNIQUE NOT NULL      -- natural: meaningful, but can change
);

What we're doing: Show why using a natural key (email) directly as a foreign key target creates a problem when that email changes, compared to referencing a stable surrogate key.

surrogate_vs_natural.sqlsql
-- natural key as primary key -- fragile
CREATE TABLE users_natural (email TEXT PRIMARY KEY);
CREATE TABLE orders_natural (id SERIAL PRIMARY KEY, user_email TEXT REFERENCES users_natural(email));

INSERT INTO users_natural VALUES ('ada@example.com');
INSERT INTO orders_natural (user_email) VALUES ('ada@example.com');

UPDATE users_natural SET email = 'ada.lovelace@example.com' WHERE email = 'ada@example.com';
-- fails by default: orders_natural still references the OLD email
2
email is both the identity and the changeable business fact — the exact conflict a surrogate key avoids.
8–9
Changing the email breaks referential integrity, since orders_natural still points at the old value — without ON UPDATE CASCADE, this UPDATE is rejected outright.
Output
ERROR:  update or delete on table "users_natural" violates foreign key constraint "orders_natural_user_email_fkey" on table "orders_natural"
DETAIL:  Key (email)=(ada@example.com) is still referenced from table "orders_natural".

Why this works: The natural key (email) is doing two jobs at once — identifying the row AND representing a real, mutable fact about the user — and those two jobs conflict the moment the fact changes. A surrogate id column would let email change freely without touching any foreign key at all, because nothing else in the schema depends on the email's specific value; the surrogate id never had any business meaning to begin with, so there is nothing about it that would ever legitimately need to change.

Using a natural key as a primary key purely to avoid an extra column

Wrong

sql
CREATE TABLE users (email TEXT PRIMARY KEY);
-- saves one column, but every referencing table now depends on email never changing

Better

sql
CREATE TABLE users (id SERIAL PRIMARY KEY, email TEXT UNIQUE NOT NULL);
-- one extra column, but every referencing table is now insulated from email changes

What you see: A routine "let the user update their email" feature turns into a cascading schema problem — either the update is blocked by foreign key constraints, or ON UPDATE CASCADE has to ripple the new email through every table that referenced it.

Why: Choosing a natural key as the primary key is a bet that the value will never need to change — a bet that is wrong far more often than it first appears, since email addresses, usernames, and similar "natural" identifiers are all realistically mutable over a system's lifetime. A surrogate key sidesteps the bet entirely by using an identifier that was never tied to a mutable real-world fact in the first place, at the modest cost of one extra column.

Surrogate vs natural key trade-offs

Surrogate vs natural key trade-offs
PropertySurrogate keyNatural key
Ever changes?no — permanent by designpossibly — if the real-world fact changes
Meaningful on its own?noyes — recognizable without a lookup
Safe as a foreign key target across many tables?yesrisky if it can change

Together

sql
CREATE TABLE users (id SERIAL PRIMARY KEY, email TEXT UNIQUE NOT NULL);
-- surrogate id for stability, natural key (email) still enforced unique

Remember: A surrogate key is stable and meaningless by design; a natural key can change when the real-world fact it represents changes — the common default is a surrogate primary key plus a UNIQUE constraint on the natural key.

See also: uuid vs integer identifiers · entities relationships cardinality

UUID vs Integer/Bigint Identifiers

standardintermediate

An integer/bigint identifier (typically via SERIAL/IDENTITY) is small, sequential, and fast to index — but it must come from the database, and it reveals row count/creation order. A UUID can be generated anywhere, before the row ever reaches the database, and does not reveal ordering — but it is larger and, if randomly generated, can fragment a B-tree index more than a sequential value would.

Think of it as

The real question is where the identifier needs to be known. If the identifier is only ever generated by the database, right before storage, a sequential integer is smaller and simpler with no real downside. If the identifier must be known before the row is written — client-side generation, merging data from multiple systems without collision, an identifier embedded in a URL that should not reveal how many rows exist — a UUID solves problems an auto-incrementing integer structurally cannot.

sql
-- sequential, database-assigned
CREATE TABLE orders (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY);

-- client-generatable, non-sequential
CREATE TABLE api_keys (id UUID PRIMARY KEY DEFAULT gen_random_uuid());

What we're doing: Compare the actual byte size of a bigint identifier against a UUID for the same number of rows, making the storage trade-off concrete.

id_size_comparison.sqlsql
SELECT pg_column_size(9223372036854775807::bigint) AS bigint_bytes,
       pg_column_size(gen_random_uuid()) AS uuid_bytes;
1
A bigint, regardless of its actual value, always occupies 8 bytes.
2
A UUID always occupies 16 bytes — exactly double, before even counting any index overhead from non-sequential insertion order.
Output
bigint_bytes | uuid_bytes
--------------+-----------
            8 |         16

Why this works: The size difference is fixed and applies to every row and every index entry referencing that key, which compounds across a large table and every foreign key column that stores a copy of it — this is the concrete cost side of the trade-off. The benefit side (client-side generation, no revealed ordering, safe merging across systems without collision) does not show up in a byte count, which is exactly why the decision needs to weigh the actual requirements rather than defaulting to whichever looks smaller.

Defaulting to UUID everywhere without a reason that actually needs it

Wrong

sql
CREATE TABLE order_line_items (id UUID PRIMARY KEY DEFAULT gen_random_uuid(), order_id UUID);
-- a purely internal table, always inserted by the same backend, with no client-side generation need

Better

sql
CREATE TABLE order_line_items (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, order_id BIGINT);
-- smaller, better index locality, and nothing here needed UUID's specific properties

What you see: A high-write-volume table using random UUIDs shows worse index bloat and slower inserts over time compared to an equivalent bigint-keyed table, with no corresponding benefit being used anywhere in the system.

Why: UUID's benefits (client-side generation, hidden ordering, collision-free merging) only pay off when the system actually needs one of those properties — a purely internal table always written by one backend process gets none of those benefits, only the storage and index-locality costs. The decision should follow from a real requirement (does this ID need to be known before insertion? does it need to avoid revealing row count?), not from UUID being perceived as a modern default.

bigint vs UUID trade-offs

bigint vs UUID trade-offs
Propertybigint (sequential)UUID (v4, random)
Size8 bytes16 bytes
Client-generatable before insert?no — database assigns ityes
Reveals row count/order?yesno
Index locality for new rowsgood — new rows cluster togetherpoor — random values scatter across the index

Together

sql
CREATE TABLE orders (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY);
CREATE TABLE api_keys (id UUID PRIMARY KEY DEFAULT gen_random_uuid());

Remember: bigint is smaller and clusters well in an index, but must be database-assigned; UUID can be generated anywhere before insertion and hides row count/order, at roughly double the storage cost and, if random, worse index locality — choose based on whether the identifier genuinely needs to exist before the database sees it.

See also: surrogate vs natural keys · uuid json arrays enums and ranges

Advertisement

Conventions and evolution

Audit fields, soft deletes, and designing for real future change without premature complexity.

Designing Audit Fields: created_at, updated_at, created_by, deleted_at

standardbeginner

created_at records when a row was inserted, set once via DEFAULT and never changed. updated_at records the most recent modification, requiring an explicit UPDATE or a trigger to keep current. created_by attributes the row to whoever created it. deleted_at marks a soft delete without physically removing the row.

Think of it as

Each audit field answers a specific question a plain row cannot answer on its own: "when did this start existing," "when did it last change," "who is responsible," "is this still active." created_at is trivial (a DEFAULT), but updated_at needs an active mechanism — nothing about a plain UPDATE statement touches it automatically, which is why it either needs a trigger or a disciplined application-level habit of always setting it.

sql
CREATE TABLE posts (
    id SERIAL PRIMARY KEY,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    created_by INT REFERENCES users(id),
    deleted_at TIMESTAMPTZ
);

CREATE FUNCTION set_updated_at() RETURNS TRIGGER AS $$
BEGIN NEW.updated_at = now(); RETURN NEW; END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER posts_set_updated_at BEFORE UPDATE ON posts
FOR EACH ROW EXECUTE FUNCTION set_updated_at();

What we're doing: Confirm created_at sets itself automatically on INSERT, while updated_at requires a trigger to stay current on UPDATE.

audit_fields_demo.sqlsql
CREATE TABLE posts (
    id SERIAL PRIMARY KEY,
    title TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE FUNCTION set_updated_at() RETURNS TRIGGER AS $$
BEGIN NEW.updated_at = now(); RETURN NEW; END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER posts_set_updated_at BEFORE UPDATE ON posts
FOR EACH ROW EXECUTE FUNCTION set_updated_at();

INSERT INTO posts (title) VALUES ('Hello world');
SELECT pg_sleep(1);
UPDATE posts SET title = 'Hello, world!' WHERE id = 1;

SELECT created_at = updated_at AS unchanged_since_insert FROM posts WHERE id = 1;
4
created_at defaults to now() at INSERT time and is never touched again.
8–13
The trigger is what actually keeps updated_at current — without it, an UPDATE would leave updated_at at its original INSERT-time value forever.
16
A 1-second pause makes the before/after difference in updated_at unambiguous in the output.
Output
unchanged_since_insert
------------------------
f

Why this works: created_at and updated_at start out identical at INSERT time, since both default to now() in the same statement — the trigger is the only reason they diverge after the UPDATE, because it explicitly overwrites NEW.updated_at with the current time on every update. Without that trigger (or equivalent application-level discipline), updated_at would still read false as "unchanged," giving a misleading impression that the row was never modified after creation.

Adding an updated_at column without a mechanism to actually keep it current

Wrong

sql
CREATE TABLE posts (id SERIAL PRIMARY KEY, updated_at TIMESTAMPTZ DEFAULT now());
UPDATE posts SET title = 'new title' WHERE id = 1;
-- updated_at is untouched -- still shows the original INSERT time, now misleadingly "correct-looking"

Better

sql
CREATE TRIGGER posts_set_updated_at BEFORE UPDATE ON posts
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- guarantees updated_at reflects the true last-modified time, regardless of which code path performs the UPDATE

What you see: A column named updated_at appears to always have a plausible-looking timestamp, but actually never changes after the row's creation — silently misleading anyone who trusts it to mean "last modified."

Why: DEFAULT only fires on INSERT — it has no effect on subsequent UPDATE statements, so a column relying solely on DEFAULT to populate itself will never update after the row is first created. A BEFORE UPDATE trigger is the reliable fix because it runs for every UPDATE regardless of which application code, script, or tool performs it — the same "enforced from any code path" property that makes database-level mechanisms more trustworthy than an application-level convention of "remember to set updated_at."

The four common audit fields

The four common audit fields
FieldSet byAnswers
created_atDEFAULT now() — automaticwhen did this start existing?
updated_ata trigger, or explicit application codewhen did this last change?
created_byapplication code, from the current userwho is responsible for this row?
deleted_atan explicit UPDATE (soft delete)is this row still active?

Together

sql
CREATE TABLE posts (
    id SERIAL PRIMARY KEY,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    deleted_at TIMESTAMPTZ
);

Remember: created_at sets itself via DEFAULT and never changes; updated_at needs an active mechanism (a trigger, or disciplined application code) since DEFAULT only fires on INSERT — deleted_at IS NULL/IS NOT NULL is the soft-delete convention, requiring every query to filter on it explicitly.

See also: soft deletes · on delete and on update

Soft Deletes: Query, Uniqueness and Cleanup Implications

coreintermediate

A soft delete marks a row as deleted (typically via a deleted_at timestamp) instead of physically removing it. Every query that should only see active rows must now explicitly filter deleted_at IS NULL — PostgreSQL has no built-in concept of "soft deleted" that queries automatically respect.

Think of it as

A soft delete trades one hard problem (recovering physically deleted data) for a different, ongoing one (every single query must remember to filter out deleted rows, forever, in every code path). Nothing about deleted_at is magic — a plain SELECT * FROM users still returns "deleted" users unless the WHERE clause says otherwise. This is the central cost of soft deletes, and it is why they are a deliberate trade, not a strictly safer default.

sql
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    email TEXT NOT NULL,
    deleted_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX users_email_active_key ON users (email) WHERE deleted_at IS NULL;

What we're doing: Show a plain UNIQUE constraint blocking a legitimate re-signup with a deleted user's email, then fix it with a partial unique index.

soft_delete_uniqueness.sqlsql
CREATE TABLE users (id SERIAL PRIMARY KEY, email TEXT UNIQUE NOT NULL, deleted_at TIMESTAMPTZ);

INSERT INTO users (email) VALUES ('ada@example.com');
UPDATE users SET deleted_at = now() WHERE email = 'ada@example.com';
-- soft-deleted, but the UNIQUE constraint doesn't know or care

INSERT INTO users (email) VALUES ('ada@example.com');
-- ERROR: still blocked -- plain UNIQUE applies regardless of deleted_at

-- fix: drop the plain UNIQUE, add a partial one instead
ALTER TABLE users DROP CONSTRAINT users_email_key;
CREATE UNIQUE INDEX users_email_active_key ON users (email) WHERE deleted_at IS NULL;
INSERT INTO users (email) VALUES ('ada@example.com');
-- succeeds -- the old row is soft-deleted, so it's excluded from this uniqueness check
3–5
Soft-deleting a row sets deleted_at, but a plain UNIQUE constraint has no awareness of that column at all.
7–8
Re-signing up with the same email fails, even though the original account is conceptually gone.
11–12
The partial index only enforces uniqueness among rows where deleted_at IS NULL — the soft-deleted row no longer counts.
Output
ERROR:  duplicate key value violates unique constraint "users_email_key"
DETAIL:  Key (email)=(ada@example.com) already exists.

INSERT 0 1

Why this works: A plain UNIQUE index has no concept of "this row doesn't count anymore" — it enforces uniqueness across literally every row in the table regardless of any other column's value, including deleted_at. A partial index with a WHERE clause changes what the index actually covers: CREATE UNIQUE INDEX ... WHERE deleted_at IS NULL builds an index (and therefore a uniqueness guarantee) over only the rows matching that condition, so a soft-deleted row's email genuinely stops counting toward the constraint the moment deleted_at is set.

Adding soft deletes to a table without revisiting its existing UNIQUE constraints

Wrong

sql
-- users table already had UNIQUE (email) before soft deletes were introduced
ALTER TABLE users ADD COLUMN deleted_at TIMESTAMPTZ;
-- the pre-existing UNIQUE constraint is now silently wrong for the new soft-delete model

Better

sql
ALTER TABLE users ADD COLUMN deleted_at TIMESTAMPTZ;
ALTER TABLE users DROP CONSTRAINT users_email_key;
CREATE UNIQUE INDEX users_email_active_key ON users (email) WHERE deleted_at IS NULL;

What you see: Introducing soft deletes into an existing table appears to work at first, then a support ticket arrives about a user unable to re-register with an email that "should" be free, because the old account was only soft-deleted.

Why: Adding a deleted_at column does not automatically update any constraint that existed before it — a plain UNIQUE constraint continues enforcing uniqueness across every row exactly as before, with no awareness that some rows are now meant to be excluded. Every existing UNIQUE constraint on a table being converted to soft deletes needs to be explicitly reviewed and, in most cases, converted to a partial index scoped to active rows.

Plain UNIQUE vs a partial unique index on a soft-deletable table

UNIQUE (email)

  • +applies across ALL rows, deleted or not
  • +a "deleted" user's email blocks a new signup with that same email
  • +the natural key is permanently reserved, even after soft deletion

UNIQUE (email) WHERE deleted_at IS NULL

  • applies only among active rows
  • a new signup can reuse a deleted user's email
  • matches the actual business intent of "deleted means gone, for uniqueness purposes"
  • UNIQUE (email)
    • applies across ALL rows, deleted or not
    • a "deleted" user's email blocks a new signup with that same email
    • the natural key is permanently reserved, even after soft deletion
  • UNIQUE (email) WHERE deleted_at IS NULL
    • applies only among active rows
    • a new signup can reuse a deleted user's email
    • matches the actual business intent of "deleted means gone, for uniqueness purposes"

Soft delete implications and their fixes

Soft delete implications and their fixes
ImplicationFix
Every query must filter deleted_ata view (e.g. active_users) that pre-filters it
UNIQUE blocks reusing a natural key from a deleted rowa partial unique index, scoped to deleted_at IS NULL
Deleted rows accumulate forevera periodic cleanup job to hard-delete old soft-deleted rows

Together

sql
CREATE UNIQUE INDEX users_email_active_key ON users (email) WHERE deleted_at IS NULL;

Remember: Soft deletes require every query to explicitly filter deleted_at IS NULL — nothing does this automatically — and any existing UNIQUE constraint needs converting to a partial index scoped to active rows, or a soft-deleted row's natural key stays permanently reserved.

See also: audit fields · composite keys and unique constraints

Designing for Future Changes Without Premature Complexity

standardintermediate

A good schema accommodates realistic future changes (a new column, a new relationship) cheaply, without paying the cost of speculative flexibility for changes that may never happen. The signal to watch is genuinely likely near-term needs, not "what if we eventually need X" for every conceivable X.

Think of it as

There is a real cost on both sides: a schema too rigid forces painful migrations for changes that were entirely foreseeable, while a schema over-engineered for flexibility (an EAV table, a fully generic "attributes" JSONB blob everywhere, unused nullable columns "just in case") pays a permanent tax in query complexity and lost type safety for flexibility that may never be used. The discipline is distinguishing "this will very likely need to change" (add a nullable column later — cheap) from "this might theoretically need to be arbitrary" (usually not worth generalizing preemptively).

sql
-- cheap, common evolution
ALTER TABLE customers ADD COLUMN phone TEXT;

-- more involved, still standard
ALTER TABLE customers ADD COLUMN status TEXT NOT NULL DEFAULT 'active';

What we're doing: Add a new nullable column to an existing populated table, confirming it is a cheap, ordinary operation rather than something requiring speculative up-front design.

schema_evolution.sqlsql
CREATE TABLE customers (id SERIAL PRIMARY KEY, name TEXT);
INSERT INTO customers (name) VALUES ('Ada'), ('Grace'), ('Linus');

-- months later, a real business need appears: track phone numbers
ALTER TABLE customers ADD COLUMN phone TEXT;

SELECT * FROM customers;
2
Three existing rows, created with no anticipation of a phone column.
5
Adding a nullable column to a populated table is a fast, standard schema change — existing rows simply get NULL for the new column.
Output
id | name  | phone
---+-------+-------
 1 | Ada   |
 2 | Grace |
 3 | Linus |
(3 rows)

Why this works: PostgreSQL adding a nullable column with no default is a fast, metadata-only operation — it does not need to rewrite every existing row, since a NULL for a new column requires no physical change to rows that predate the column. This is exactly why "we might need a phone field later" does not justify building a speculative generic-attributes system up front: the actual cost of adding the real, typed column later, when the need is confirmed, turns out to be low.

Building a fully generic key-value attributes table to avoid ever needing a future migration

Wrong

sql
CREATE TABLE customer_attributes (
    customer_id INT REFERENCES customers(id),
    key TEXT,
    value TEXT
);
-- "future-proof" -- but now every real attribute loses its type, its NOT NULL/CHECK constraints, and needs a pivot to query normally

Better

sql
ALTER TABLE customers ADD COLUMN phone TEXT;
-- a real column: typed, indexable, constrainable, and just as cheap to add when the need actually arrives

What you see: Querying "customers with a phone number starting with 555" (or any similarly simple real-world question) now requires a pivot or a self-join against the generic attributes table, and nothing prevents storing a phone number as the text 'not-a-phone-number' since there is no CHECK constraint possible on a generic value column.

Why: A fully generic attributes table trades away everything a real typed column offers — CHECK constraints, indexes suited to the actual data type, NOT NULL, foreign keys — in exchange for flexibility that, per the earlier example, was not actually expensive to defer. The premature-complexity trap is paying that cost permanently, for every future attribute, in exchange for avoiding a migration that would have been fast and simple when the real need eventually appeared.

A concrete future need vs a speculative one

A concrete future need vs a speculative one
NeedConcrete/likely?Design response
"We will probably add a phone number field soon"concrete, likelyadd it now, or add a nullable column when needed — cheap either way
"We might someday need arbitrary custom fields per customer"speculative, uncertain shapea JSONB column for that specific case, not a fully generic EAV schema across the board
"Every table might need a totally dynamic set of attributes"speculative, unlikely to materialize as designeddo not build this preemptively — real columns with real constraints, until a real need appears

Together

sql
ALTER TABLE customers ADD COLUMN phone TEXT;
-- cheap: adding a nullable column does not rewrite the table

Remember: Adding a nullable column to an existing table is cheap in PostgreSQL — that cost asymmetry is the argument against premature generalization (EAV tables, generic attribute blobs) for flexibility needs that are still speculative rather than concrete.

See also: normalization · soft deletes

Advertisement