Skip to main content

Cascade Operations & Referential Integrity in SQLAlchemy

Cascade operations automatically propagate changes (insert, update, delete) from parent objects to related children. Referential integrity constraints ensure database consistency by preventing orphaned records and invalid references. Together, cascades and constraints form the safety net of a relational database.

Understanding Cascade Operations

Cascade rules define what happens to child records when the parent is modified or deleted. SQLAlchemy supports several cascade options that apply to relationships:

from sqlalchemy import Column, Integer, String, ForeignKey
from sqlalchemy.orm import declarative_base, relationship

Base = declarative_base()

class User(Base):
__tablename__ = "users"

id = Column(Integer, primary_key=True)
username = Column(String(50), unique=True, nullable=False)

# Define cascade behavior for child addresses
addresses = relationship(
"Address",
back_populates="user",
cascade="all, delete-orphan" # Cascade options
)

class Address(Base):
__tablename__ = "addresses"

id = Column(Integer, primary_key=True)
street = Column(String(200), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)

user = relationship("User", back_populates="addresses")

Cascade is defined on the parent side of the relationship. The string "all, delete-orphan" means:

  • all: Include save-update, merge, refresh-expire, expunge behavior
  • delete-orphan: Delete children if they're no longer referenced

Cascade Options Explained

OptionBehavior
save-updateAdd new related objects to session when adding parent (default)
deleteDelete children when parent is deleted
delete-orphanDelete children if they lose all parent references
mergeMerge child objects when merging parent
refresh-expireRefresh child objects when refreshing parent
expungeRemove child from session when removing parent
allIncludes save-update, merge, refresh-expire, and expunge

Use cascade="all, delete-orphan" for owned relationships (user owns addresses). Use cascade="all, delete" when deleting a parent should clean up children. Use no cascade (default) for loosely coupled relationships.

Examples: When to Use Cascades

Cascade Delete: Owned Relationships

When a user is deleted, their addresses should be deleted too (the user owns the addresses):

class User(Base):
__tablename__ = "users"

id = Column(Integer, primary_key=True)
username = Column(String(50), nullable=False)

addresses = relationship(
"Address",
back_populates="user",
cascade="all, delete-orphan"
)

# Usage
with Session(engine) as session:
user = session.query(User).filter_by(id=1).first()
session.delete(user)
session.commit()
# Both user and all their addresses are deleted

Cascade Save-Update: Automatic Session Management

By default, adding a parent automatically adds related children to the session:

class Order(Base):
__tablename__ = "orders"

id = Column(Integer, primary_key=True)
order_date = Column(DateTime, default=datetime.utcnow)

items = relationship(
"OrderItem",
back_populates="order",
cascade="all, delete-orphan" # Includes save-update by default
)

# Usage
with Session(engine) as session:
order = Order()
item1 = OrderItem(product="Widget", quantity=5)
item2 = OrderItem(product="Gadget", quantity=3)

order.items = [item1, item2]
session.add(order)
# item1 and item2 are automatically added to the session
session.commit()
# All objects are inserted in one transaction

No Cascade: Loose Associations

When books belong to a library but can be reassigned, don't use cascade:

class Book(Base):
__tablename__ = "books"

id = Column(Integer, primary_key=True)
title = Column(String(300), nullable=False)
library_id = Column(Integer, ForeignKey("libraries.id"))

# No cascade—deleting a library doesn't delete its books
library = relationship("Library", back_populates="books")

# Usage
with Session(engine) as session:
library = session.query(Library).filter_by(id=1).first()
session.delete(library)
session.commit()
# Library is deleted; books remain (with library_id = NULL if nullable)

Referential Integrity Constraints

Referential integrity is enforced at the database level via foreign key constraints. A foreign key ensures that every reference points to a valid record:

from sqlalchemy import ForeignKey

class Book(Base):
__tablename__ = "books"

id = Column(Integer, primary_key=True)
title = Column(String(300), nullable=False)

# Foreign key: author_id MUST reference an existing authors.id
author_id = Column(Integer, ForeignKey("authors.id"), nullable=False)

# Usage
with Session(engine) as session:
book = Book(title="New Novel", author_id=999) # Invalid author ID
session.add(book)
session.commit() # Raises IntegrityError—no author with id=999

SQLAlchemy lets you define cascading deletes and updates at the database level:

author_id = Column(
Integer,
ForeignKey("authors.id", ondelete="CASCADE", onupdate="CASCADE"),
nullable=False
)

The ondelete="CASCADE" tells the database to delete child records when the parent is deleted. The onupdate="CASCADE" updates child foreign keys if the parent's primary key changes.

Cascade vs. Database Constraints

SQLAlchemy cascades operate at the ORM level (Python). Database constraints operate at the database level (SQL). Use both for a complete safety net:

ScenarioSQLAlchemy CascadeDatabase Constraint
Delete parentcascade="delete"ondelete="CASCADE"
Add child without parentPrevented by ForeignKeyRejected by database
Update parent IDcascade="save"onupdate="CASCADE"

SQLAlchemy cascades are checked when you call .commit(). Database constraints are checked by the database itself, protecting against direct SQL modifications.

Cascade with Many-to-Many Relationships

Many-to-many relationships usually don't need cascades (deleting one author shouldn't delete books):

from sqlalchemy import Table

book_author = Table(
"book_author",
Base.metadata,
Column("book_id", Integer, ForeignKey("books.id")),
Column("author_id", Integer, ForeignKey("authors.id")),
)

class Book(Base):
__tablename__ = "books"

id = Column(Integer, primary_key=True)
title = Column(String(300), nullable=False)

# No cascade—deleting an author doesn't delete books
authors = relationship(
"Author",
secondary=book_author,
back_populates="books"
)

With association objects, use careful cascades:

class BookAuthor(Base):
__tablename__ = "book_author"

book_id = Column(Integer, ForeignKey("books.id"), primary_key=True)
author_id = Column(Integer, ForeignKey("authors.id"), primary_key=True)

# Cascade deletes when either book or author is deleted
book = relationship("Book", cascade="all, delete")
author = relationship("Author", cascade="all, delete")

Troubleshooting Cascade Issues

Issue: Children Not Deleted When Parent Deleted

Problem: You delete a parent, but children remain in the database.

Solution: Add cascade="all, delete" or cascade="all, delete-orphan" to the relationship. Or set ondelete="CASCADE" on the foreign key.

Issue: Orphaned Records After Removing from Relationship

Problem: You remove a child from a parent's relationship, but it's not deleted.

Solution: Use cascade="delete-orphan" on the relationship. This deletes children that lose all parent references.

Issue: IntegrityError on Insert

Problem: You try to insert a record with an invalid foreign key.

Solution: Ensure the parent record exists and is committed before inserting the child. Or use cascade to automatically add parents to the session.

Key Takeaways

  • Cascades are ORM-level rules that propagate changes to children (save, delete, merge, expunge).
  • cascade="all, delete-orphan" for owned relationships (user owns addresses).
  • cascade="all, delete" when deleting a parent should delete children.
  • No cascade (default) for loose associations (books belong to many libraries).
  • Database constraints (ondelete="CASCADE") enforce referential integrity at the SQL level.
  • Use both SQLAlchemy cascades and database constraints for complete safety.

Frequently Asked Questions

What's the difference between delete and delete-orphan?

delete deletes children when the parent is deleted. delete-orphan deletes children if they lose all parent references (stronger—useful for owned relationships).

Should I use SQLAlchemy cascades or database constraints?

Use both. SQLAlchemy cascades protect your ORM; database constraints protect against direct SQL. A defense-in-depth approach prevents bugs.

If I set cascade at the ORM level, do I still need database constraints?

Yes. If someone modifies the database directly (via SQL or another tool), database constraints ensure consistency. SQLAlchemy cascades are bypassed.

Can I set different cascades on different relationships?

Yes. Each relationship can have its own cascade rules. This is common in complex schemas.

What happens if I delete a parent without setting cascade?

If no cascade is set, SQLAlchemy checks the foreign key constraint. If the constraint is NOT NULL, deletion fails. If nullable, the foreign key is set to NULL (if ondelete="SET NULL").

Further Reading