Constraints Are Documentation That Can't Go Stale

NOT NULL, unique indexes, and foreign keys aren't red tape. They're the only documentation of your data rules that is enforced, tested, and impossible to ignore.

ordersNOT NULLUNIQUECHECK qty > 0can't go stalewiki, 2023
Garick
Senior Database Engineer
Dec 29, 2025
6 min read

The first thing I do when I join a codebase is read the schema, top to bottom, like a novel. It's a strange novel — no plot, lots of foreign keys — but it tells me more truth than any README, because a README describes what someone intended and a schema describes what the database enforces. And here's what I've noticed over a couple of decades of reading these things: the schemas I trust are dense with constraints, and the schemas that scare me are wide-open fields of nullable columns with no keys between them.

A constraint is documentation with teeth. NOT NULL on orders.customer_id doesn't just say every order belongs to a customer — it makes the alternative impossible. That's a sentence of documentation that can never go stale, never drift from reality, and never be skipped by a developer in a hurry. I want to make the case that you should read — and review — constraints exactly this way.

The app-only validation trap

The most common objection I hear: "We validate this in the application." And the application probably does! The Rails model has validates :email, presence: true, uniqueness: true, the tests pass, everyone sleeps well.

Here's the uncomfortable list of things that walk right past your model validations:

  • update_column, insert_all, upsert_all, and every other skip-callbacks pathway
  • Bulk backfills and data migrations (where mistakes hurt most)
  • A second service or a reporting script writing to the same database
  • A human in a psql console at 11pm, fixing something quickly
  • Race conditions — the classic one deserves its own section

Application validations are a UX feature: they exist to give users friendly error messages. Database constraints are an integrity feature: they exist to make bad states unrepresentable. You want both, and they are not substitutes for each other.

The uniqueness race, briefly

validates :email, uniqueness: true works by running a SELECT before the INSERT. Two requests arrive at the same moment, both SELECTs find nothing, both INSERTs succeed. Congratulations, you have two accounts with the same email and a support ticket with your name on it. The fix is one line, and it's not in the model:

add_index :users, :email, unique: true

The unique index closes the race and documents the invariant in the one place every future reader will check. Keep the model validation too — it's the polite error message. The index is the law.

Foreign keys: the relationships you meant to have

A customer_id column without a foreign key is a promise made with fingers crossed. With the constraint, the database guarantees the referenced row exists — and, just as valuable, forces you to decide what deletion means:

add_foreign_key :orders, :customers                      # deletion blocked if orders exist
add_foreign_key :comments, :posts, on_delete: :cascade   # comments die with the post
add_foreign_key :audit_logs, :users, on_delete: :nullify # logs outlive the user

That on_delete choice is a genuine design decision about your domain, and the foreign key is where it gets recorded. Without it, "what happens to orders when we delete a customer?" is answered by whichever code path deletes the customer — and different code paths will answer differently. I have cleaned up after that movie. It's long and nobody likes the ending.

Orphaned rows are the quiet kind of corruption: nothing crashes, reports are just slightly wrong, joins silently drop records, and by the time someone notices, you can't tell which orphans were bugs and which were "intentional."

NOT NULL and CHECK: saying what must be true

NOT NULL is the humblest constraint and the highest-value one. Every column should be NOT NULL unless you can articulate what NULL means for it (a topic worth its own article). When I review a migration, a nullable column with no explanation gets the same question every time: "when is this legitimately unknown?"

CHECK constraints extend the idea to domain rules that are cheap to state and expensive to violate:

ALTER TABLE orders
  ADD CONSTRAINT price_cents_nonnegative CHECK (price_cents >= 0),
  ADD CONSTRAINT valid_status
    CHECK (status IN ('pending', 'paid', 'shipped', 'canceled'));

Read those as sentences: prices are never negative; a status is one of exactly four words. New engineer, day one, learns the order lifecycle from the schema itself. That's what I mean by executable documentation — it's the documentation a 2am debugger actually gets to rely on.

Reviewing for missing constraints

This skill matters more right now than ever, because a growing share of migrations are drafted by AI assistants. The pattern I see repeatedly in review: the generated model has beautiful, thorough validations — presence, uniqueness, format, the works — and the generated migration has a bare column with none of it mirrored. The model looks careful, which makes the gap easy to miss. The assistant learned from a million Rails tutorials, and the tutorials mostly stop at validations too.

So my review checklist for any migration, human- or machine-written:

  1. Every _id column: is there a foreign key? What's the on_delete intent?
  2. Every column: nullable on purpose, or by omission? (null: false is a decision; its absence usually isn't.)
  3. Every "must be unique" rule in the model: is there a unique index backing it?
  4. Enumish strings and numeric ranges: would a CHECK constraint state the rule?
  5. Does the schema, read alone with no application code, still tell the truth about this data?

That last one is the real test. Code gets rewritten — I've watched Rails apps become services, services become other services. The database and its rules are what persist across all of it. Data outlives code; constraints are how the data remembers the rules after the code that knew them is gone.

One honest caveat

Constraints on large production tables need care when added: validating a foreign key or NOT NULL across a billion rows takes locks you'll want to manage (Postgres's NOT VALID + VALIDATE CONSTRAINT two-step is your friend). That's a deployment problem, not a design objection — and it's a strong argument for adding constraints early, while the table is small and the invariant is cheap to adopt.

Takeaways for your next review

  • Model validations are UX; constraints are integrity. Ship both, trust only the second.
  • Every uniqueness rule needs a unique index — the validation alone is a race condition with good manners.
  • Every foreign key column deserves an actual foreign key and a deliberate on_delete answer.
  • Default to NOT NULL; nullable should be an argued-for exception.
  • When reviewing generated migrations, check whether the validations made it into the schema. That gap is today's most common integrity bug.

Write your constraints like you're leaving notes for an engineer ten years from now who has your database and none of your code. Because, odds are, you are.

constraintsdata-integrityschema-designrailspostgres
Written by
Garick
Senior Database Engineer · Firetrail review team
More Database