Skip to content

CRUD - Delete Record

Deleting records should be done carefully (data loss is permanent).

python
with app.app_context():
    user = User.query.get(1)
    if user:
        db.session.delete(user)
        db.session.commit()

Many applications avoid hard delete.

Instead they add a flag:

  • is_deleted

And filter it out by default.

This helps:

  • auditing
  • recovery

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”
orm_delete.py
victim = db.session.scalar(sa.select(User).where(User.name == "bob"))
db.session.delete(victim)
db.session.commit()
bulk_delete.py
result = db.session.execute(sa.delete(User).where(User.name == "cy"))
db.session.commit()
result.rowcount          # 1

Measured: 3 rows, ORM-deleting bob left 2; the bulk delete matched 1 row and left 1.

diagram Diagram mermaid

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.

missing.py
victim = db.session.scalar(sa.select(User).where(User.id == 999))
db.session.delete(victim)      # AttributeError if victim is None

scalar() returns None when nothing matches, so guard it. In a view, the idiomatic form turns the miss into a 404:

view.py
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”
soft.py
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.

sketch ORM delete against bulk delete p5.js
session.delete loads the object and runs cascades. sa.delete issues one statement and reports rowcount, with no ORM involvement.
pch.quizTag pch.quizDefaultTitle
  1. What does db.session.delete(obj) do that sa.delete(Model).where(...) does not?

    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.

  2. Why should a delete action never be a plain link?

    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.

  3. db.session.scalar(select(User).where(User.id == 999)) returns None and you pass it to session.delete. What happens?

    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.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading