Data integrity and safety averages 51.6 out of 100 across the 20 third-party apps scored on it in my June and July 2026 audits. The schema is where to start: nulls nobody chose, emails that repeat, deletes that cascade too far. A database review checklist asks 6 questions of every table, then draws the diagram a reviewer reads.

What these controls are, together: the database review checklist and the data model diagram

A database schema review checklist works through 6 questions for every table: are the types right, which columns may be null, does every reference have a foreign key, what must be unique, what happens to children on delete, and which column names the owner or tenant. The data model diagram is the seventh item.

Those 20 apps are the third-party apps I audited in June and July 2026 and could score on this pillar, a selected set rather than a random sample, so the average is not a rate for AI-built apps in general. The six questions are the schema part of the data consistency checklist for SaaS, the wider list for keeping a small app’s data correct and recoverable.

#QuestionA bad answer looks likeThe fix
1Are the types right?Money in a floating-point column, dates stored as text, two kinds of id across tablesMoney as integer cents or numeric, which the PostgreSQL manual recommends for monetary amounts (real and double precision are inexact); timestamps as timestamp with time zone, stored as UTC; one kind of id throughout
2Which columns may be null?Every column nullable, because nullable is the defaultNOT NULL on every column the code reads as always present; nullable only where someone decided it
3Does every reference have a foreign key?An org_id column with no constraint behind itA foreign key on every column that points at another table’s row
4What must be unique?Email, slug or external id with a plain index or no indexA unique index; on lower(email) where case must not matter
5What happens to children on delete?The same rule on every relationship, chosen by nobodyAn ON DELETE rule chosen per relationship
6Which column names the owner or tenant?Tenant-owned tables with no org_id or workspace_idA tenant column on every tenant-owned table, NOT NULL and foreign-keyed
7Can someone who was not there read it?No diagram, or one drawn once and never updatedA data model diagram showing entities, relationships and ownership boundaries

The review is of a schema that already exists and already holds data, and that changes every fix. When a constraint is added, Postgres normally scans the table to check that the rows already there satisfy it, so a rule the current data breaks will not go on. The order is always the same: find the violations, clean them, then add the rule. In the Production Hardening Sprint this review is deliverable 4.1, Schema integrity review, and the diagram is deliverable 4.13, Data model diagram. Indexes and slow queries are a separate review, which starts with how to analyze a Postgres query.

What goes wrong without them

Four symptoms show up long before anyone opens the schema, and each has a query that finds it in the data you already hold.

SymptomThe missing ruleThe query that finds it
Two accounts share one emailA unique index on the email columnSELECT lower(email), count(*) FROM users WHERE email IS NOT NULL GROUP BY lower(email) HAVING count(*) > 1;
A page crashes for a few users onlyNOT NULL on a column the code treats as always presentSELECT table_name, column_name FROM information_schema.columns WHERE table_schema = 'public' AND is_nullable = 'YES'; lists the columns that accept nulls, then SELECT count(*) FROM users WHERE display_name IS NULL; counts the rows one of them breaks
Reports count rows whose parent is goneA foreign key on the child columnSELECT count(*) FROM invoices i LEFT JOIN orgs o ON o.id = i.org_id WHERE i.org_id IS NOT NULL AND o.id IS NULL;
Deleting one workspace took the invoices with itAn ON DELETE rule chosen per relationshipThe foreign-key listing below, read down its on_delete column

The column listing in row two also returns the columns of views, which information_schema.columns describes alongside tables; skip any name that information_schema.tables marks as VIEW rather than BASE TABLE. In row three, the i.org_id IS NOT NULL condition matters: without it the count also takes invoices that never had an org, which is a question about nullability, not an orphan. The columns worth running it on are the _id columns that do not appear in the listing below.

-- Every foreign key in the app's own schema, with its delete rule decoded
SELECT conrelid::regclass  AS child_table,
       confrelid::regclass AS parent_table,
       conname             AS constraint_name,
       CASE confdeltype
         WHEN 'a' THEN 'NO ACTION'
         WHEN 'r' THEN 'RESTRICT'
         WHEN 'c' THEN 'CASCADE'
         WHEN 'n' THEN 'SET NULL'
         WHEN 'd' THEN 'SET DEFAULT'
       END                 AS on_delete
FROM pg_constraint
WHERE contype = 'f'
  AND connamespace = 'public'::regnamespace
ORDER BY on_delete, child_table;

The listing reads pg_constraint, where contype = 'f' marks a foreign key and confdeltype holds the delete rule as a single letter, which the CASE turns back into words. Filtering on connamespace keeps it to the public schema, so a hosted database’s managed schemas, such as Supabase’s auth and storage, stay out, while a public table that references auth.users stays in. Read it down the on_delete column. Every CASCADE row whose child table holds records the business must keep is a finding, and chains count: if workspaces cascade to projects and projects cascade to invoices, deleting a workspace reaches the invoices.

The database allows duplicate email rows

Two accounts with one email split one person in two. A password reset goes to one account, billing attaches to one and usage to the other, and support cannot tell which is real. It happens when the only uniqueness check lives in application code: the app looked for an existing row, found none, and two requests passed that check at the same moment. Case makes it worse, because A@example.com and a@example.com are different strings to an index on the raw column. The first row of the symptom table finds both kinds, since it groups on lower(email).

In a point-of-sale app I audited in July 2026, the users table had a primary key, one foreign key and three plain indexes, none of them unique. Two rows could share an email and one sign-up could bind both to a single login, leaving the person’s store and role to whichever row a LIMIT 1 returned. An index that is not unique only makes the lookup fast; only a unique index makes the database refuse the second row.

Null values breaking app queries

A nullable column the code treats as always present fails quietly. The page crashes only for the users whose row holds a null, which is why it looks like a bug nobody can reproduce. A filter such as WHERE status != 'cancelled' skips every row where status is null, because a comparison with null yields null rather than true, and WHERE keeps only true rows. Most aggregates, sum and avg among them, discard null inputs, so totals and averages leave those rows out. Nobody chose nullable: it is what Postgres gives a column when nothing else is said. The second row of the symptom table lists the candidates.

Orphans and over-eager deletes

Without a foreign key, child rows can point at parents that no longer exist. An invoice list joined to organizations then drops every invoice whose org is gone, and the report stops adding up to what was billed. The opposite failure is ON DELETE CASCADE on the wrong relationship, where deleting one workspace removes invoices the business must keep. The third row of the symptom table finds the orphans, and the foreign-key listing finds the cascades. How deletes reach each kind of data, class by class, is set out in the data-loss bugs hiding in an AI-built app.

No one understands the database structure

Picture the schema after a year of prompting: forty tables, a third of them unused, column names from three different sessions (userId, user_id, owner). A new developer cannot read the schema, so every change is a guess, and when a reviewer asks for the data model there is nothing to send. The fix is the seventh question: a diagram generated from the schema and kept beside it. It belongs with the rest of documentation for a vibe-coded app, the set a new engineer reads on day one.

How to set them up

One rule covers every fix below: find the existing violations, clean them, add the constraint in a migration that follows the database migration checklist, then test that the database rejects a bad write. The Postgres behavior in this section comes from the PostgreSQL manual (version 18, the current release), and I quote it wherever a lock, a scan or a version matters.

What is a foreign key constraint, and what unique constraints guarantee that code cannot

A foreign key constraint makes the database refuse a row that references a missing parent. A unique index makes it refuse the second copy, even when two requests arrive at the same instant. Application checks cannot promise either: Rails’ uniqueness validation queries for an existing record right before the save, and its own guide says duplicates can still occur.

In the words of PostgreSQL’s constraints chapter, a foreign key “specifies that the values in a column (or a group of columns) must match the values appearing in some row of another table”, and its delete action decides whether removing a parent is refused or carried through to the children. A unique constraint ensures a value “is unique among all the rows in the table”, and adding one “will automatically create a unique B-tree index”. Both hold however many requests race, because the database checks every write.

The Rails validation of uniqueness is the clearest case of a check that loses the race. The Rails guide’s uniqueness section says it “does not create a uniqueness constraint in the database”, that two database connections can “create two records with the same value”, and that “you must create a unique index on that column in your database.” The same holds for any look-then-insert written with Prisma, Drizzle or the Supabase client. The fix is the unique index, plus code that treats SQLSTATE 23505, unique_violation, as “already exists” instead of a crash.

For email, index lower(email): the manual notes that a unique index on lower(col1) prevents rows that “differ only in case”, and a query can use that index when it filters on lower(email) as well. On a large live table, CREATE UNIQUE INDEX CONCURRENTLY builds without the locks that “prevent concurrent inserts, updates, or deletes”, takes longer, and cannot run inside a transaction block; if it fails on a duplicate, it leaves an invalid index to drop before trying again. Uniqueness within a workspace, such as one project slug per workspace, is a two-column unique index on (workspace_id, slug).

-- 1. Emails held by more than one row, in any case
SELECT lower(email) AS email, count(*)
FROM users
WHERE email IS NOT NULL
GROUP BY lower(email)
HAVING count(*) > 1;
-- 2. After merging those rows, refuse the next duplicate
CREATE UNIQUE INDEX CONCURRENTLY users_email_lower_key ON users (lower(email));

How to add a NOT NULL constraint in Postgres without breaking the app

Adding a NOT NULL constraint in Postgres to a live table takes 5 steps: count the nulls, agree the backfill value, backfill in batches, add and validate a NOT VALID check constraint on a large table, then set NOT NULL.

  1. 01 Count the rows where the column is null, for example display_name on users, so the size of the backfill is known.
  2. 02 Agree the backfill value with whoever owns the data: a default, a value derived from another column, or deleting the row.
  3. 03 Backfill in batches, so no single update runs for long or touches the whole table at once.
  4. 04 On a large table, add CHECK (display_name IS NOT NULL) NOT VALID, then VALIDATE CONSTRAINT. On a small table, skip this step.
  5. 05 Run SET NOT NULL, drop the helper check in a separate statement, add a column default for new rows where one makes sense, and update the ORM schema so its types match.
ALTER TABLE users ADD CONSTRAINT users_display_name_nn
  CHECK (display_name IS NOT NULL) NOT VALID;
ALTER TABLE users VALIDATE CONSTRAINT users_display_name_nn;
ALTER TABLE users ALTER COLUMN display_name SET NOT NULL;
ALTER TABLE users DROP CONSTRAINT users_display_name_nn;

The sequence works because of one sentence in PostgreSQL’s ALTER TABLE reference: “if a valid CHECK constraint exists (and is not dropped in the same command) which proves no NULL can exist, then the table scan is skipped.” That skip is documented from version 12; the version 11 page has no such line. VALIDATE CONSTRAINT “acquires a SHARE UPDATE EXCLUSIVE lock”, so writes carry on while it scans. SET NOT NULL still takes the default ACCESS EXCLUSIVE lock, so the helper check removes the scan, not the lock. From version 18 the manual also allows a not-null constraint to be added NOT VALID directly and validated later, which does the same job without the helper check; on older servers, keep the CHECK path. On a small table, ALTER TABLE ... SET NOT NULL alone is fine.

Set up cascade delete correctly

Postgres offers 5 delete actions on a foreign key, and the data class chooses between them, never the ORM default. CASCADE suits rows with no meaning without their parent, such as sessions and draft items. RESTRICT suits invoices and audit rows. SET NULL suits authorship on content that outlives its author.

The middle column below follows the manual. The last column is my working rule, not a Postgres statement: I choose each relationship’s action by what the child rows mean to the business.

ON DELETE optionWhat Postgres doesTypical use
NO ACTIONThe default. The delete proceeds, but the foreign key must still hold, so it usually ends in an error; on a deferrable constraint the check can wait until later in the transactionWhatever nobody chose; treat it as an unreviewed RESTRICT
RESTRICTPrevents deletion of a referenced row; the check cannot be deferredInvoices, payments, ledger entries, audit rows
CASCADEDeletes the referencing rows as wellSessions, reset tokens, draft items, a profile row that exists only for its user
SET NULLSets the referencing columns to nullThe author column on comments or documents that outlive the author
SET DEFAULTSets the referencing columns to their default values; the operation fails if the default would not satisfy the foreign keyReassigning rows to a placeholder owner row

On Supabase, the common case is a table that references auth.users. Supabase’s guide to managing user data says to “Specify on delete cascade in the reference” and shows a profiles table built that way. That is right for a profile row. It is wrong for an invoices table, and users can be deleted straight from the dashboard under Authentication > Users, so decide the rule for every table that points at a user before anyone deletes an account. Records that should come back after a mistake call for a flag instead of any delete action, which is what soft delete is.

SaaS database architecture: one database, many customers

SaaS database architecture comes in 3 shapes: shared tables with a tenant column, a separate schema or database per tenant, or a separate Postgres instance per tenant. A small SaaS wants the first, with the tenant id NOT NULL, foreign-keyed and leading the composite indexes on every tenant-owned table.

AWS’s partitioning models for multi-tenant PostgreSQL call these pool, bridge and silo. Microsoft’s SaaS tenancy patterns state the trade-off of the first shape plainly: a multitenant database “necessarily sacrifices tenant isolation”, in general has “the lowest per-tenant cost”, and “must have one or more tenant identifier columns”. In the table, the shape and cost columns use their words; the middle column is my reading for a small SaaS.

ShapeFits whenWhat it costs
Pool: all tenants in shared tables in one schema, separated by row-level securityAlmost every small SaaSSignificantly more cost-effective, less operational overhead; the noisy neighbor cannot be completely eliminated
Bridge: a separate schema or database per tenant, as logical divisions inside PostgreSQLA few tenants need their own copy of the schemaExtra provisioning for every tenant; the same noisy neighbor concerns as the pool
Silo: a Postgres instance per tenantA customer contract demands isolationOften difficult to run cost-effectively; onboarding is more complicated and time-consuming

For a small SaaS the answer is shared tables. My working rule for them: every tenant-owned table gets a tenant id that is NOT NULL, has a foreign key to the tenant table, and comes first in the composite indexes, so each query for one tenant’s rows starts from that column. The review walks every table, checks for that column, and lists the tables without it; that list is the finding. A database per tenant earns its cost only when a customer contract demands the isolation. How the column is enforced, with row-level security, scoped queries and a two-tenant test, belongs to multi-tenant data isolation.

DDL scripts: exporting a schema a reviewer can read

DDL scripts are the schema written as CREATE and ALTER statements. Generate them from the live Postgres database with pg_dump --schema-only, commit the file, and diff it against a dump of an empty database built from the migrations: a difference means production was changed outside the migrations or a migration never ran.

DDL is data definition language: the CREATE, ALTER and DROP statements that define structure rather than rows, the subject of PostgreSQL’s data definition chapter. A DDL script is the whole schema as text, which is what a reviewer wants to read.

To generate DDL from a live database, run pg_dump against it with the three flags below; pg_dump’s documentation says the tool “does not block other users accessing the database (readers or writers)”. For a Postgres schema export a reviewer can read, --schema-only dumps “only the object definitions (schema), not data or statistics”, --no-owner leaves out the commands that set ownership, and --no-privileges leaves out the grant and revoke commands. Commit the output, so every later review starts from a baseline.

pg_dump --schema-only --no-owner --no-privileges --restrict-key=schemadiff -f prod.sql "$PROD_DATABASE_URL"
pg_dump --schema-only --no-owner --no-privileges --restrict-key=schemadiff -f fresh.sql "$FRESH_DATABASE_URL"
diff prod.sql fresh.sql

The diff that means something compares production with an empty database built from the migrations folder, dumped with the same flags. Without --restrict-key, pg_dump writes a random key into every plain-text dump, so two dumps never match; the manual names this option for “comparing dump files” and warns it is “not recommended for general use”, so keep those two files for the comparison and restore only from dumps made without it. The option exists only in pg_dump releases from August 2025 on (18, 17.6, 16.10, 15.14, 14.19, 13.22); an older pg_dump rejects it but also writes no random key, so drop the flag there. Every line of that diff is a finding to explain.

On Supabase, supabase db dump “Runs pg_dump in a container with additional flags to exclude Supabase managed schemas”, which keeps auth, storage and extension schemas out of the diff; after supabase db reset rebuilds the local database from the migrations, supabase db dump --local dumps that copy. The ORMs can do the same comparison from their side: Prisma’s db pull rewrites schema.prisma from the live database (it overwrites the file, so commit first) and drizzle-kit pull generates a schema.ts from it, and a git diff shows what drifted. Without a terminal, pgAdmin’s documentation gives the click path: right-click the database in the tree, choose Backup…, pick the Plain format, open the Data Options tab and move the Only schemas switch on, then click Backup.

In the file, a reviewer reads the constraints first, then the indexes, then the row-level security policies.

The data model diagram

A data model diagram for a reviewer fits on one page: tables grouped by domain, the tenant boundary drawn, personal-data tables marked, and the delete rule written on each relationship. Generate it from the schema, then edit it for reading.

The diagram shows entities, relationships and ownership boundaries, and someone who was not there when the app was built can follow it. Generating it from the schema, and again after each schema change, keeps it from drifting away from what the database holds. Editing it for reading takes four passes: group the tables by domain (accounts, billing, content); hide join tables and audit tables; mark the tenant boundary and every table that holds personal data; write the ON DELETE rule on each line that has one. The one-page version goes in the data room. The full generated version, in Mermaid’s entity relationship diagram syntax, lives in the repository beside the schema dump, where a pull request can update both. Generating an ERD from Postgres is part of how to create an architecture diagram.

How to verify each one

A schema review is verified by trying to break it. Test that the database constraints reject bad data with 4 writes that must fail, delete a parent and check each child table followed its rule, then compare the diagram against the actual schema and the schema against the migrations.

Run the checks against a copy of the database loaded with representative data, never against production.

  1. 01 Insert a second user whose email differs from an existing one only in case. Expect a unique violation, SQLSTATE 23505.
  2. 02 Insert a child row whose parent id does not exist. Expect a foreign key violation, 23503.
  3. 03 Insert a row with a required column set to null. Expect a not-null violation, 23502.
  4. 04 Insert a tenant-owned row with no tenant id. Expect 23502.
  5. 05 Delete a parent row and confirm each child table did what its rule says: cascaded, refused with 23503, or set its column to null.
  6. 06 Check that every table and foreign key in the exported DDL appears on the diagram, and that nothing on the diagram is missing from the DDL.
  7. 07 Diff the production schema dump against the dump of an empty database built from the migrations. Expect no difference.

Keep each error message and SQLSTATE code with the date you ran it: that transcript is the evidence. In the Production Hardening Sprint, these checks close both deliverables. Deliverable 4.1 is verified this way: test rejected invalid records and expected relationship behavior using representative data. Deliverable 4.13 is verified this way: compare the diagram against the delivered schema and migrations. Re-run the checks after every migration that touches a constraint. Pool sizes and connection limits need a different test: connection pooling in Postgres.

Where the sprint does this

In the Production Hardening Sprint, deliverable 4.1 reviews and corrects types, nullability, foreign keys, uniqueness rules and cascade behavior, and deliverable 4.13 delivers a readable diagram of entities, relationships and ownership boundaries for the data room, each closed by the checks in the section before this one. Both are recorded in the production readiness report, deliverable 13.1, which is verified this way: account for all 123 IDs; keep failures visible until resolved and explain genuine non-applicable items. We work on the schema the app already has: its current framework and hosting setup are the starting point, and components are refactored or replaced where the production work requires it. Building new product features or modules, completing unfinished core features or business workflows, and rebuilding core functionality that does not yet perform its intended job are outside the sprint. Every item is listed in the published scope.

Common questions about reviewing a database schema

How to get duplicate email in SQL?

Group on the lowercased email and keep the groups with more than one row: SELECT lower(email) AS email, count(*) AS copies FROM users WHERE email IS NOT NULL GROUP BY lower(email) HAVING count(*) > 1; returns each email held by more than one row, whatever the case. Merge or remove the extra rows with the account owner, then run CREATE UNIQUE INDEX users_email_lower_key ON users (lower(email)); so the database refuses the next duplicate.

To choose which row survives, keep the one that billing and the most recent sign-ins point at, move the other row’s child records onto it, and only then delete the extra.

How to add a constraint to an existing table in SQL?

Use ALTER TABLE ... ADD CONSTRAINT after cleaning out the rows that break the rule, because Postgres checks the existing rows when the constraint is added. On a big table, add a foreign key or check constraint (and, from PostgreSQL 18, a not-null constraint) with NOT VALID, then run VALIDATE CONSTRAINT, which takes a SHARE UPDATE EXCLUSIVE lock instead of blocking writes. A unique rule cannot be added NOT VALID; on a live table, build it with CREATE UNIQUE INDEX CONCURRENTLY instead.

How to resolve foreign key constraint error?

Fix the data, not the rule: insert or restore the parent first, correct the id, or delete the children before the parent. SQLSTATE 23503, foreign_key_violation, means the rule is working: a write points at a parent that does not exist, or a delete would leave children pointing at nothing. Set CASCADE only where the data class calls for it, and never drop the constraint to make the error go away.

What does DDL stand for?

DDL stands for data definition language: the statements, such as CREATE, ALTER and DROP, that define a database’s structure rather than its data. In Postgres, pg_dump --schema-only writes a database’s DDL out as a script.

Is create a DDL or DML?

CREATE is DDL, along with ALTER and DROP, because it defines structure. INSERT, UPDATE and DELETE are DML, the statements that change rows, and the PostgreSQL manual splits them the same way: creating tables is in its Data Definition chapter, and inserting, updating and deleting rows is in its Data Manipulation chapter.