CRUD - Delete Record
Deleting records should be done carefully (data loss is permanent).
Example
Section titled “Example”with app.app_context():
user = User.query.get(1)
if user:
db.session.delete(user)
db.session.commit()Soft deletes (common pattern)
Section titled “Soft deletes (common pattern)”Many applications avoid hard delete.
Instead they add a flag:
is_deleted
And filter it out by default.
This helps:
- auditing
- recovery
Cascades
Section titled “Cascades”If other tables depend on the record (foreign keys), deleting may:
- fail due to integrity constraints, or
- delete child records (if cascade is configured)
Understand your relationships before enabling cascade delete.
Two ways to delete, with different consequences
Section titled “Two ways to delete, with different consequences”victim = db.session.scalar(sa.select(User).where(User.name == "bob"))
db.session.delete(victim)
db.session.commit()result = db.session.execute(sa.delete(User).where(User.name == "cy"))
db.session.commit()
result.rowcount # 1Measured: 3 rows, ORM-deleting bob left 2; the bulk delete matched 1 row and left 1.
flowchart TD A["session.delete(obj)"] --> A1["loads the object first"] A1 --> A2["cascades run
events fire
related objects handled"] B["sa.delete(Model).where(...)"] --> B1["one DELETE statement"] B1 --> B2["no objects loaded
no ORM cascades
rowcount tells you how many"]
The ORM form is the safe default: it knows about relationships and will cascade according to how you configured them. The bulk form is for deleting many rows at once, and it does exactly what the SQL says — nothing more.
Deleting something that is not there
Section titled “Deleting something that is not there”victim = db.session.scalar(sa.select(User).where(User.id == 999))
db.session.delete(victim) # AttributeError if victim is Nonescalar() returns None when nothing matches, so guard it. In a view, the idiomatic
form turns the miss into a 404:
from flask import abort
user = db.session.get(User, user_id)
if user is None:
abort(404)
db.session.delete(user)
db.session.commit()db.session.get(Model, pk) is the right call for a primary-key lookup — it checks the
identity map first and only queries if it must.
Soft delete is often what you actually want
Section titled “Soft delete is often what you actually want”class User(db.Model):
deleted_at: Mapped[datetime | None]
user.deleted_at = datetime.now(timezone.utc)
db.session.commit()Nothing is destroyed, the row can be restored, and references from other tables stay valid. The cost is that every query must now exclude soft-deleted rows — easy to forget, so centralise it in one helper rather than repeating the filter.
See it move
Section titled “See it move”Check yourself
Section titled “Check yourself”-
What does db.session.delete(obj) do that sa.delete(Model).where(...) does not?
The ORM form knows about relationships. The bulk form is one statement with no objects loaded, which is why it is fast and why it reports rowcount instead.
pch.quizShowAnswer
B — it loads the object and runs ORM cascades and events for related rows — The ORM form knows about relationships. The bulk form is one statement with no objects loaded, which is why it is fast and why it reports rowcount instead.
-
Why should a delete action never be a plain link?
This is a real cause of data loss. Use a form that POSTs with a CSRF token, or send DELETE from JavaScript.
pch.quizShowAnswer
B — crawlers, prefetchers and link preloading can follow a GET and destroy data without a user ever clicking — This is a real cause of data loss. Use a form that POSTs with a CSRF token, or send DELETE from JavaScript.
-
db.session.scalar(select(User).where(User.id == 999)) returns None and you pass it to session.delete. What happens?
scalar returns None when nothing matches. Use db.session.get(Model, pk) for a primary-key lookup and check for None before acting.
pch.quizShowAnswer
B — an AttributeError, so the lookup must be guarded — in a view, with abort(404) — scalar returns None when nothing matches. Use db.session.get(Model, pk) for a primary-key lookup and check for None before acting.
pch.coffeeTagline
pch.coffeeCtapch.feedbackHeading
pch.feedbackSubheading