Skip to content
EL
Contents

Normalization and schema design

Review modeling patterns: polymorphism, EAV and soft delete

DecisionCore4 min read20 min hands-on#sql#data-modeling

The idea in 30 seconds

Three patterns appear in almost every codebase: one table that points at several kinds of thing, a generic attribute/value table, and rows marked deleted instead of removed. Each is convenient and each quietly gives up something the database would otherwise guarantee. Know what you give up, and the alternatives that keep it.

Helps to know first: Constraints and data integrity, Joins and relational querying

On this page

The situation

The shop wants three features: comments on both products and orders, flexible product specifications (screen size, capacity, refresh rate), and the ability to close a customer account without losing their order history. An AI assistant proposes all three at once.

Review taskComments, product attributes and account deletion

Pull request: “Flexible schema for comments, specs and soft delete.”

CREATE TABLE comments (
  id                integer PRIMARY KEY,
  commentable_type  text,      -- 'product' or 'order'
  commentable_id    integer,   -- id in that table
  body              text
);

CREATE TABLE product_attributes (
  product_id  integer,
  attribute   text,            -- 'size_inches', 'refresh_hz', ...
  value       text
);

ALTER TABLE customers ADD COLUMN deleted_at timestamptz;   -- set instead of DELETE

Would you approve it? Write the review you would leave.

Your call

Choosing a pattern

NeedPatternKeepsGives up
A row belongs to one of several kinds of parentOne FK column per kind + CHECK (num_nonnulls(...) = 1)Referential integrityA column per kind
type + id columns (polymorphic)FlexibilityEvery foreign key guarantee
Attributes that vary by productReal columnsTypes, constraints, simple queriesA migration per new attribute
jsonb columnFlexibility, one row per productEasy constraints (needs explicit checks)
EAV tableUnlimited flexibilityTypes, constraints and readable queries
Hide records without destroying historydeleted_at + partial unique indexesHistory in placeA filter in every query
Archive tableClean live tableA move step, and joins for history
A fixed set of valuesCHECK (status IN (...))SimplicityChanging the set needs a migration
Lookup table + foreign keyValues with extra data (labels, ordering)One more join

Check yourself

  1. Why can’t a polymorphic commentable_id column have a foreign key?

    Show answer

    A foreign key references exactly one table, but the column’s meaning depends on another column’s value. Nothing checks that the referenced row exists.

  2. A soft-deleted customer cannot sign up again with the same email. Why, and how do you fix it?

    Show answer

    The unique constraint still counts the deleted row. Replace it with a partial unique index ON customers (email) WHERE deleted_at IS NULL, so uniqueness applies only to live rows.

  3. Name two things an EAV table gives up compared with real columns.

    Show answer

    Typed values (everything is text), and constraints such as required or unique attributes. Queries also need self-joins or pivots for every attribute.

Finished reading?

Saved in this browser only.

Sources

Where each claim on this page comes from.

  1. 1.
    PostgreSQL documentation: 5.5 Constraints
    Primary· §5.5.1 Check Constraints, §5.5.5 Foreign Keys

    A foreign key column references exactly one table; a check constraint can require any condition across columns.

  2. 2.
    PostgreSQL documentation: 9.2 Comparison Functions and Operators
    Primary· Table 9.3 Comparison Functions

    num_nonnulls returns the number of non-null arguments.

  3. 3.
    PostgreSQL documentation: 11.8 Partial Indexes
    Primary· §11.8, Example 11.3

    A unique index can be made partial with a WHERE clause, so uniqueness applies only to the rows matching it.

Go further

What to read next, and why it is worth the time.

  1. 1.
    PostgreSQL documentation: 11.8 Partial Indexes
    Primary· §11.8 Partial Indexes

    Partial unique indexes solve a whole family of "unique, but only among the live rows" problems.

Applies to PostgreSQL 18. Draft: not yet fact-checked end to end.