ON DELETE CASCADE — The $2.3M Revenue Discrepancy
A cleanup script deleted 340 customers and silently removed 14,000 orders via CASCADE.
20+ years shipping high-throughput database systems. Drawn from code that ran under real load.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- Database relationships are rules describing how rows in one table connect to rows in another
- One-to-Many (1:N): one parent, many children — FK lives on the child table
- Many-to-Many (M:N): both sides connect to many — always requires a junction table
- One-to-One (1:1): rare — use only for sparse data, security isolation, or proven query performance
- Always declare ON DELETE behaviour explicitly — RESTRICT is the safe default
- Missing UNIQUE on a junction table composite key silently corrupts COUNT and aggregation queries
Think of a library. One library card belongs to exactly one person — but that person can borrow many books, and each book can be borrowed by many different people over time. That's the whole concept of database relationships: it's just a set of rules describing how rows in one table connect to rows in another. Get those rules right and your data stays clean and consistent forever. Get them wrong and you'll be untangling duplicate rows at 2am.
Every real application — a Netflix, an Airbnb, a humble todo list — is powered by tables that talk to each other. The moment you store a user's orders, a product's reviews, or a student's enrolled courses, you're dealing with database relationships. They're not optional theory; they're the skeleton your entire data model is built on.
A wrong relationship can corrupt data silently, make queries nightmarishly slow, or force you to rewrite half your schema six months into production. The problem they solve is data redundancy and integrity — without relationships, you'd copy a customer's name and address into every single order row.
By the end of this article you'll be able to identify which relationship type belongs in a given scenario, write the SQL to implement each one correctly with foreign keys, design a clean junction table for many-to-many links, and avoid the three classic mistakes that trip up even experienced developers.
Why Database Relationships Are Not Optional
A database relationship is a logical link between two tables, enforced by foreign keys that guarantee referential integrity. The core mechanic: a column in one table references the primary key of another, preventing orphaned rows and ensuring every child has a valid parent. Without relationships, your data is just a collection of unrelated spreadsheets.
In practice, relationships define cardinality — one-to-one, one-to-many, many-to-many — and dictate how deletions propagate. ON DELETE CASCADE is one such rule: when a parent row is deleted, all dependent child rows are automatically removed. This is not magic; it's a declarative constraint executed at the database level, bypassing application code entirely. The key property: it's atomic and consistent — no partial deletions, no race conditions.
Use relationships and cascade rules when child data has no meaning without its parent — orders without customers, comments without posts. In real systems, failing to define them leads to silent data corruption: dangling references that crash joins, inflate counts, and produce phantom revenue. A $2.3M discrepancy often starts with a missing foreign key.
One-to-Many: The Relationship You'll Use 80% of the Time
A One-to-Many (1:N) relationship means one row in Table A can be associated with many rows in Table B, but each row in Table B points back to exactly one row in Table A. Classic examples: one customer → many orders, one blog post → many comments, one department → many employees.
The pattern is always the same: the 'many' side holds the foreign key. An order row holds a customer_id. A comment row holds a post_id. You never put a list of IDs inside the 'one' side — relational databases don't store arrays in columns, and if you find yourself wanting to, that's a design smell.
Why does this matter so much? Because it's the primary tool for eliminating redundancy. You store the customer's name and email exactly once in the customers table. Every order just references that one row. Update the email in one place and every order instantly reflects it. That's referential integrity — the database guarantees the customer_id in every order actually exists in the customers table, because you declared a FOREIGN KEY constraint.
Many-to-Many: Why You Always Need a Junction Table
A Many-to-Many (M:N) relationship means rows on both sides can relate to multiple rows on the other side. Students enrol in many courses; each course has many students. A product appears in many orders; each order contains many products. Doctors treat many patients; patients see many doctors.
Here's the critical insight: you cannot model M:N directly between two tables. There's no column you can add to students or courses that handles multiple associations cleanly. What you need is a third table — a junction table (also called a bridge or associative table) — that turns the M:N into two separate 1:N relationships.
The junction table holds the foreign keys from both sides and its own primary key. But here's where it gets interesting: the junction table often carries its own meaningful data. An enrolment isn't just a link — it has an enrolment date, a grade, a status. That extra data is what makes the junction table a first-class entity in your schema, not just plumbing. When you recognize that, your design becomes far more expressive and your queries become cleaner.
One-to-One: Rare but Powerful for Schema Partitioning
A One-to-One (1:1) relationship means each row in Table A corresponds to at most one row in Table B, and vice versa. It's the least common relationship type — and beginners often ask: why not just put all those columns in one table?
The answer is: sometimes you should. But there are three legitimate reasons to split into a 1:1 relationship. First, optional data: a users table might have profile details (bio, avatar, website) that only some users ever fill in. Keeping sparse, optional columns in a separate user_profiles table avoids storing NULL across millions of rows. Second, security partitioning: store sensitive data like password hashes or payment tokens in a separate table with tighter access controls. Third, performance: if you have a table with 50 columns and some queries only ever need 5 of them, splitting into a 'hot' and 'cold' table dramatically reduces the I/O per query.
The implementation is a foreign key on the dependent table that also has a UNIQUE constraint, enforcing that no two rows can point to the same parent.
Self-Referencing Relationships: When a Table Points to Itself
A self-referencing (or recursive) relationship is when a row in a table has a foreign key pointing to another row in the same table. It sounds strange until you see the use cases: an employees table where each employee has a manager_id that points to another employee, a categories table where subcategories have a parent_category_id, or a comments table with threaded replies.
This is one of those patterns that feels clever the first time you see it, but it comes with tradeoffs. The big advantage is that you don't need a separate managers table or a separate categories table for each level of hierarchy — the structure is infinitely deep by design. The tradeoff is that querying hierarchical data in SQL requires recursive Common Table Expressions (CTEs), which not all developers are comfortable writing.
Knowing this pattern exists — and knowing when it's cleaner than a separate table — is a mark of a developer who thinks about schema design holistically rather than just creating tables reactively.
- Each row has a parent_id pointing to another row in the same table
- Root nodes have parent_id = NULL — the recursion anchor
- Recursive CTEs walk from root to leaves by joining child to parent at each level
- The structure is infinitely deep — no fixed number of tables needed
- Trade-off: querying requires recursive CTEs, which not all developers write comfortably
Foreign Keys: The Only Thing Preventing Orphaned Data
You've drawn a line on a whiteboard connecting 'users' to 'orders'. That's cute. Now make the database enforce it before a bulk delete screws your production reporting.
Foreign keys aren't decoration. They're the safety net that stops you from having orders referencing deleted users, or user profiles pointing to non-existent addresses. Without them, you're relying on application-level discipline — which breaks the second someone runs a raw UPDATE.
The pain point hits hardest during cascading deletes or when you try to backfill data. If your foreign key isn't indexed, every JOIN between these tables becomes a full table scan. That's how a simple page load turns into a 30-second query.
Here's the rule: define the constraint AND index the referencing column. The constraint prevents data rot. The index makes your joins fast. Most ORMs create the constraint but forget the index. Your database doesn't care about your ORM's opinion.
Composite Keys: When a Single Column Can't Uniquely Identify a Row
Ignore the cargo cult that tells you every table needs a surrogate integer primary key. Sometimes real-world data won't fit that mold.
A composite primary key — using two or more columns — is mandatory when no single column is unique, but the combination naturally is. Think enrollment tables: student_id + course_id. A student can take many courses, a course has many students, but the pair is unique.
Why not just slap an auto-increment 'id' on there? You can, but then you lose the built-in uniqueness enforcement for the real-world combination. You'd need a separate unique constraint on the pair. That's redundant, and it's another index burning disk space. The composite key is both primary key and unique constraint in one.
The tradeoff is real: composite keys make your JOIN queries longer to write, and they complicate foreign key references from child tables. If 'enrollments' has details in a 'grades' table, you'll need to repeat both columns in the foreign key. That's verbose but correct.
Only use composites when the pair is genuinely the natural key and stable (won't change). Avoid them for transactional tables where speed of inserts matters more than logical purity.
Ternary and Higher-Degree Relationships: Why Binary Assumptions Fail
Most databases model relationships between two entities: a student enrolls in a course. But real-world constraints often involve three or more entities simultaneously. Ternary relationships (three entities) solve cases a chain of binary tables cannot. Example: a doctor prescribes a specific medication to a specific patient. That's not three separate one-to-many links — it's one fact constrained by all three. If you model doctor-patient, patient-medication, and medication-doctor as separate pairs, you can insert invalid combinations. A ternary table DoctorPrescription(DoctorID, PatientID, MedicationID) with a composite primary key enforces that a prescribing event is exactly one row. Higher-degree relationships work the same way but require more columns. The cost is query complexity — joining four or five tables for one fact. Only use ternary+ when a single business rule involves all entities simultaneously; otherwise, decompose into binary relationships.
Mapping Cardinalities: One Number Changes Everything
Mapping cardinality defines the maximum number of relationship instances one entity can participate in. The four formal options — one-to-one, one-to-many, many-to-one, many-to-many — dictate physical schema design. A one-to-many cardinality creates a foreign key on the 'many' side. A many-to-many cardinality always demands a junction table. Many-to-one is simply the inverse perspective of one-to-many. Choosing the wrong mapping cardinality guarantees duplicate data or missing links. For example, labeling a department-to-employee relationship as many-to-many would allow an employee to belong to multiple departments simultaneously — which might be correct for a matrix organization, but incorrect for a strict reporting hierarchy. Always audit each relationship with two questions: 'How many of entity A can relate to one B?' and 'How many of B can relate to one A?' The numbers set the foreign key placement and table structure from day one.
Self-Referential Relationships: Hierarchical Data Models
Self-referential relationships occur when a table references itself. This is essential for modeling hierarchical data like organizational charts, category trees, or threaded comments. The foreign key points to the primary key within the same table. For example, an employees table can have a manager_id column that references employee_id. This allows you to represent a tree structure where each employee reports to a manager. However, querying hierarchical data in SQL requires recursive Common Table Expressions (CTEs) to traverse the tree. Without careful indexing, performance can degrade on deep hierarchies. A common pitfall is creating cycles (e.g., an employee being their own manager), which can be prevented with a CHECK constraint or application logic. Always use ON DELETE SET NULL or ON DELETE CASCADE carefully—cascading deletes can wipe out entire subtrees unintentionally. For deeper hierarchies, consider using nested sets or materialized path patterns for faster reads.
Many-to-Many with Junction Table: Best Practices
Many-to-many relationships require a junction table (also called associative or linking table) to break the M:N relationship into two one-to-many relationships. The junction table contains foreign keys referencing the primary keys of the two related tables, and often includes additional attributes like timestamps or quantities. Best practices include: always define a composite primary key on the two foreign key columns to prevent duplicate associations; use surrogate primary keys only if you need to reference the association itself; index both foreign key columns individually for efficient joins; and consider using ON DELETE CASCADE on both foreign keys so that deleting a parent row automatically removes associated junction rows. Avoid storing business logic in the junction table that could be normalized into a separate entity. For example, an orders and products table connect via order_items with quantity and price. This design is robust and scalable. Always validate that the junction table's foreign keys are not nullable to ensure data integrity.
Polymorphic Associations: Anti-Pattern or Valid Design?
Polymorphic associations allow a foreign key to reference multiple tables by storing both the target table name and the target ID. This is common in Rails-like frameworks for features like comments or tags that can belong to different entities. However, this design is often considered an anti-pattern because it bypasses referential integrity—the database cannot enforce that the foreign key points to a valid row. Additionally, queries become complex and indexing is less effective. A better approach is to use separate junction tables or concrete foreign keys for each relationship. If you must use polymorphic associations, enforce integrity at the application level and consider using a single table inheritance pattern. In PostgreSQL, you can use table inheritance or partitioning to simulate polymorphic behavior with proper constraints. For most cases, avoid polymorphic associations in favor of explicit foreign keys. They may seem flexible but introduce long-term maintenance and performance issues.
Orphaned Orders Broke Revenue Reports After Cascade Delete
- Never use ON DELETE CASCADE on tables with financial or audit data
- Always run a COUNT query on child tables before deleting parent rows
- Use ON DELETE RESTRICT as your default — switch to CASCADE only when child data is genuinely meaningless without the parent
- Soft-delete (deleted_at) is almost always safer than hard-delete for customer-facing data
SELECT student_id, course_id, COUNT(*) FROM enrolments GROUP BY student_id, course_id HAVING COUNT(*) > 1;SHOW INDEX FROM enrolments;| File | Command / Code | Purpose |
|---|---|---|
| one_to_many_orders.sql | CREATE TABLE customers ( | One-to-Many |
| many_to_many_enrolments.sql | CREATE TABLE students ( | Many-to-Many |
| one_to_one_user_profiles.sql | CREATE TABLE users ( | One-to-One |
| self_referencing_employees.sql | CREATE TABLE employees ( | Self-Referencing Relationships |
| ForeignKeyEnforcement.sql | CREATE TABLE orders ( | Foreign Keys |
| CompositePrimaryKey.sql | CREATE TABLE enrollments ( | Composite Keys |
| TernaryDoctorPrescription.sql | CREATE TABLE Doctor ( | Ternary and Higher-Degree Relationships |
| CardinalityExamples.sql | CREATE TABLE Department ( | Mapping Cardinalities |
| self_referential.sql | CREATE TABLE employees ( | Self-Referential Relationships |
| many_to_many_junction.sql | CREATE TABLE students ( | Many-to-Many with Junction Table |
| polymorphic_association.sql | CREATE TABLE comments ( | Polymorphic Associations |
Key takeaways
Interview Questions on This Topic
What is the difference between a One-to-Many and a Many-to-Many relationship, and how do you physically implement each one in SQL?
Frequently Asked Questions
20+ years shipping high-throughput database systems. Drawn from code that ran under real load.
That's Database Design. Mark it forged?
8 min read · try the examples if you haven't