Why Your Foreign Keys Might Be Lying to You: Referential Integrity vs Application Logic
Checking that a parent row exists in application code feels equivalent to a foreign key constraint. It isn't: the check races with concurrent deletes, every script and service that skips your app skips it too, and even real foreign keys can be disabled, unvalidated, or bypassed by NULLs. Here's where referential integrity actually comes from, and where each approach belongs.
The Report That Couldn't Find Its Customers
Finance runs the monthly revenue report and it crashes on a null pointer. Somebody digs in and finds 312 orders whose customer_id points at customers that don't exist. The order service has always checked that the customer exists before creating an order. There's a unit test for it. Code review would never let an insert through without that check.
So where did the orphans come from? A few came from a race: a customer was deleted in the half-second between the order service checking for them and inserting the order. Most came from a cleanup script someone ran last spring to purge test accounts. It deleted customers directly with SQL, so the application's "don't delete customers with orders" rule never ran.
The schema had no foreign key on orders.customer_id. The team had decided years ago that the application handled it. That decision held as long as every write went through one code path, one at a time, and no write ever has.
Two Ways to Say "This Must Exist"
The guarantee that every reference points at something real: every orders.customer_id matches a row in customers. A row that breaks it is an orphan.
A rule declared in the schema (customer_id REFERENCES customers(id)) that the database checks on every insert, update, and delete that could break it, inside the transaction doing the write. If the write would create an orphan, it fails.
Code that reads the database to confirm the parent exists ("SELECT the customer, and if found, INSERT the order"). It's a check-then-act sequence: two separate steps with a gap between them.
Both say the same thing. The difference is who checks, when, and for which writers. Those three differences are where application checks quietly stop working.
Gap One: Check-Then-Act Is a Race
An application check reads a fact and then acts on it as if the fact is still true. Under the default isolation level in PostgreSQL, MySQL, and most other databases, nothing stops another transaction from changing that fact in between.
Here's the interleaving that produces an orphan:
- Transaction A (order service) runs
SELECT 1 FROM customers WHERE id = 42and finds the customer. - Transaction B (account deletion) checks that customer 42 has no orders, finds none, and deletes the customer.
- B commits.
- A inserts an order for customer 42 and commits.
Each transaction did exactly what it was written to do, and B's own check ran correctly too. It looked for orders, found none (A's insert didn't exist yet), and deleted. The two checks can't see each other's in-progress work, so both pass.
A foreign key closes this gap with locking. In PostgreSQL, when you insert a child row, the database locks the referenced parent row with FOR KEY SHARE. A DELETE of that parent needs a lock that conflicts with it. Whichever transaction gets there second has to wait for the first to finish, then sees the result:
If the delete committed first, the insert fails with insert or update on table "orders" violates foreign key constraint. If the insert committed first, the delete fails (or cascades, depending on how you declared the key). Either way, no orphan.
💡 Can you fix the race in application code?
Yes, but you end up rebuilding a foreign key by hand. The order path has to SELECT ... FOR SHARE the customer, the delete path has to SELECT ... FOR UPDATE it, and every code path that touches either table has to follow the same protocol. Running both transactions at SERIALIZABLE also works, at the cost of retrying serialization failures. See Database Transactions: The Isolation Level You're Probably Not Setting for why the default level lets this through.
Gap Two: Not Every Write Goes Through Your App
The race is the subtle problem. The common one is simpler: an application check only protects the code path it's written in.
Over a system's life, writes come from far more places than the service that owns the table: a bulk import job, a backfill in a migration, a support engineer fixing data in a SQL console, a second service someone gave database credentials to "temporarily," an ORM call that skips the model's validation hooks. None of them run your check. Only a constraint in the database sits on every path.
There's also a third, quieter gap: the delete side. Most applications check that the parent exists when creating a child. Far fewer check for children when deleting a parent, and fewer still when updating a parent's key. A foreign key covers all three directions.
Try It: Race the Two Transactions
The simulator runs the two transactions from earlier. Transaction A places order 901 for customer 42 while transaction B deletes customer 42. Choose who enforces the rule, whether the transactions run one after another or concurrently, and whether B goes through the app or runs as a SQL script.
| # | Tx A · place order | Tx B · delete customer |
|---|---|---|
| 1 | SELECT 1 FROM customers WHERE id = 42 → 1 row: customer exists | |
| 2 | SELECT count(*) FROM orders WHERE customer_id = 42 → count = 0: safe to delete | |
| 3 | DELETE FROM customers WHERE id = 42 → DELETE 1 (uncommitted, row locked) | |
| 4 | INSERT INTO orders (id, customer_id) VALUES (901, 42) → INSERT 0 1 | |
| 5 | COMMIT → customer 42 gone | |
| 6 | COMMIT → order 901 saved |
Order 901 references customer 42, which no longer exists. Nothing raised an error. An application check reads the database and then acts on what it read, so anything that happens between the two goes unnoticed, and any writer that skips the application skips the check. A foreign key is checked by the database inside the writing transaction, using a row lock on the parent, for every writer.
Some combinations worth trying:
- App check, one after another, through the app. This is the case your tests cover, and it works: B sees the order and refuses.
- App check, concurrent. Both checks pass and you get an orphan. Neither transaction did anything wrong on its own.
- App check, one after another, SQL script. No concurrency needed. The script never ran the check.
- FK · RESTRICT, either timing. No orphan in any combination. When the transactions overlap, A waits on B's row lock and then fails with an error the app can handle.
- FK · CASCADE, one after another. The data is consistent, but deleting the customer deleted the order with it. That might be what you want, or it might have just deleted someone's paid invoice.
Gap Three: Even Real Foreign Keys Can Lie
Adding foreign keys doesn't end the story. There are several ways a schema can show a constraint while the data still doesn't satisfy it.
Checks were turned off and back on
MySQL's SET FOREIGN_KEY_CHECKS = 0 is common in dump files and migration scripts. When you turn it back on, MySQL does not go back and verify the rows written while it was off. In PostgreSQL, foreign keys are implemented as internal triggers, so ALTER TABLE ... DISABLE TRIGGER ALL or SET session_replication_role = replica (used by some restore and replication tools) skips them the same way.
The constraint was added NOT VALID
PostgreSQL lets you add a foreign key with NOT VALID, so it applies to new writes without scanning the existing table. That's a good way to add one to a large table without a long lock, but existing rows aren't checked until you run VALIDATE CONSTRAINT. If nobody runs that second step, old orphans stay. (Zero-Downtime Database Migrations covers this two-step pattern.)
The engine ignored the declaration
MyISAM tables accept FOREIGN KEY clauses and then ignore them. Through MySQL 8.x, even InnoDB silently ignored an inline REFERENCES written on the column definition; only a separate FOREIGN KEY (...) REFERENCES ... clause created a constraint. MySQL 9.0 started honoring inline references. If your schema dates from an older version, check information_schema.REFERENTIAL_CONSTRAINTS rather than trusting the DDL.
NULL satisfies the constraint
A nullable foreign key column accepts NULL, and a NULL isn't checked against anything. That's correct for optional relationships, but if customer_id should always be set, you need NOT NULL as well. With composite keys it's worse: under the default MATCH SIMPLE, if any column in the key is NULL, the whole row skips the check. Use MATCH FULL if partial keys shouldn't be allowed.
Exists isn't the same as active
A foreign key guarantees the parent row exists. It knows nothing about a deleted_at column. With soft deletes, orders can reference a customer the rest of your system treats as gone, and the constraint is fully satisfied. (Soft Deletes vs Hard Deletes goes into this trade-off.)
When you inherit a database, don't assume the declared constraints describe the data. A quick orphan check settles it:
SELECT o.id, o.customer_id FROM orders o LEFT JOIN customers c ON c.id = o.customer_id WHERE o.customer_id IS NOT NULL AND c.id IS NULL; -- PostgreSQL: list constraints that were never validated SELECT conrelid::regclass AS table_name, conname FROM pg_constraint WHERE contype = 'f' AND NOT convalidated;
Choosing What Happens on Delete
Once you have a foreign key, you have to decide what happens when someone deletes (or re-keys) the parent. This choice matters as much as having the constraint.
| ON DELETE | What happens | Good for |
|---|---|---|
| NO ACTION (default) | What happensDelete fails if children exist. Checked at the end of the statement, and can be deferred to commit. | Good forMost relationships. Forces the app to handle children deliberately. |
| RESTRICT | What happensSame as NO ACTION but checked immediately and can't be deferred. | Good forWhen you want the failure as early as possible. |
| CASCADE | What happensChildren are deleted along with the parent, recursively. | Good forChildren that have no meaning alone: order line items, a post's tag links. |
| SET NULL | What happensChildren stay, with the reference set to NULL. | Good forOptional links: an assigned_to user who leaves the company. |
| SET DEFAULT | What happensReference set to the column default. | Good forRare. A placeholder like an 'unassigned' row. |
⚠️ CASCADE chains reach further than you think
ON DELETE CASCADE follows every cascading key it finds. Delete a tenant, which cascades to users, which cascades to orders, which cascades to payments, and a single DELETE can remove a large part of your database in one transaction, holding locks the whole time. Use CASCADE for true parts of a whole, and keep it off relationships that hold business records.
Foreign Keys Aren't Free
The usual reasons teams skip foreign keys are performance and flexibility. Some of those concerns are real; most have a fix.
Write overhead. Every child insert looks up the parent through its primary key index. That's one index lookup per row, which is rarely noticeable unless you're bulk-loading millions of rows. Even then, you can load first and add the constraint with validation afterward.
The missing child index. This one causes real outages. PostgreSQL does not create an index on the referencing column. When you delete a customer, the database has to find that customer's orders, and without an index on orders.customer_id that's a sequential scan of the whole orders table, for every deleted customer. InnoDB creates the index automatically; PostgreSQL leaves it to you. (See Why Your Index Isn't Being Used if you've added it and it still isn't helping.)
Lock contention on hot parents. Because each child insert takes a share lock on the parent row, thousands of concurrent inserts referencing the same parent (one tenant row, one popular product) all lock that row. Share locks don't block each other, but they conflict with updates to the parent's key, and in PostgreSQL many simultaneous share lockers are tracked through a structure called MultiXact that can become a bottleneck at very high rates. If you hit this, it shows up in lock wait metrics and is worth measuring before you redesign around it.
Constraints across boundaries. A foreign key can only reference a table in the same database. Once customers live in one service's database and orders in another's, or the two tables are on different shards, there's nothing to declare. Many sharded systems drop foreign keys for this reason. (Database Sharding: When One Database Becomes Many covers why cross-shard guarantees get expensive.)
That last case is the one where application logic really is the only option, and you need to design for it rather than hope.
When the Database Can't Enforce It
Across service or shard boundaries, accept that references can dangle for some period, and build the system to notice and handle it:
- Validate at write time, knowing it's a best effort. Calling the customer service before creating an order catches most mistakes. It can't stop a delete that happens a second later.
- Don't hard-delete referenced entities. Have the customer service publish a "customer deleted" event and keep a tombstone, so other services can react and lookups return "deleted" rather than "never existed."
- Run a reconciliation job. Periodically compare references against the source of truth and flag or repair orphans. Without it, you won't find out about dangling references until something like the revenue report breaks.
- Make readers tolerate missing parents. Code that follows a cross-service reference should handle "not found" deliberately instead of crashing.
For multi-step operations across services that need to be undone together, see The Saga Pattern.
Where Each Belongs
Application checks and foreign keys solve different problems, so most systems need both:
| Foreign key | Application check | |
|---|---|---|
| Guarantees integrity under concurrency | Foreign keyYes, via row locks | Application checkNo, unless you add locking yourself |
| Covers scripts, consoles, other services | Foreign keyYes | Application checkNo |
| Error a user can understand | Foreign keyNo, a constraint violation | Application checkYes: 'This customer has open orders' |
| Business rules ('no unpaid invoices') | Foreign keyNo | Application checkYes |
| Works across databases or shards | Foreign keyNo | Application checkYes, as a best effort |
Use the application check for the user experience and for business rules: check early, return a clear message, and avoid most constraint errors in the first place. Use the foreign key as the guarantee: it catches the race you didn't anticipate and the script you didn't know about. When the application check misses something, the foreign key turns it into an error instead of corrupted data.
Do the referencing and referenced tables live in the same database (and the same shard)?
Key Takeaways
- An application check is check-then-act. Under default isolation, a concurrent delete can land between the check and the insert, and both transactions' checks pass.
- Application checks only cover their own code path. Scripts, imports, consoles, and other services skip them. A foreign key is enforced on every write to the table.
- Foreign keys use locks to stay correct. The child insert locks the parent row, so the conflicting transaction waits and then fails cleanly.
- Declared doesn't mean enforced. Disabled checks,
NOT VALIDconstraints, ignored inlineREFERENCES, NULLs, and soft deletes all leave constraints that don't match the data. Run an orphan query on any database you inherit. - Choose ON DELETE on purpose. CASCADE is for parts of a whole, not for business records.
- Index the referencing column. PostgreSQL doesn't do it for you, and parent deletes scan the child table without it.
- Use both. The app check gives users a clear message; the foreign key guarantees the data. Across service boundaries, where no constraint exists, plan for dangling references and reconcile them.
References
- PostgreSQL docs: Foreign Keys
- PostgreSQL docs: Row-Level Locks, including
FOR KEY SHARE - PostgreSQL docs: ALTER TABLE, for
NOT VALIDandVALIDATE CONSTRAINT - MySQL docs: FOREIGN KEY Constraints, including
foreign_key_checks