Chapter 06 · SQLite Data Modification: INSERT, UPDATE, DELETE, UPSERT, and RETURNING
DELETE, Cascades, Soft Delete, and Data Lifecycle
Delete deliberately by verifying targets, measuring cascade scope, distinguishing hard from soft deletion, and understanding why free pages are not the same as a smaller file.
Learning outcomes
DELETE is simple syntactically and potentially large operationally. A one-row parent DELETE can remove many child rows through CASCADE, and deleting rows usually creates reusable free pages rather than immediately shrinking the database file.
Use verify-before-delete discipline and explicit transactions.
Measure direct target rows separately from foreign-key cascade effects.
Choose hard delete versus soft delete from lifecycle requirements.
Explain why row deletion does not imply immediate file-size reduction.
Use freelist_count as storage evidence without treating VACUUM as routine cleanup.
Build a transaction-protected cleanup lab with before/after counts.
DELETE targets rows exactly like a SELECT predicate
A DELETE without WHERE removes every row in the table. With WHERE, only rows for which the predicate is true are targeted. Preview first.
DROP TABLE IF EXISTS cleanup_note;CREATE TABLE cleanup_note( note_id INTEGER PRIMARY KEY, device_code TEXT NOT NULL, status TEXT NOT NULL, occurred_at TEXT NOT NULL);INSERT INTO cleanup_note(device_code,status,occurred_at) VALUES('PUMP-007','closed','2025-01-01'),('PUMP-007','open','2026-08-12'),('FAN-014','closed','2024-05-01');SELECT note_id, device_code, status, occurred_atFROM cleanup_noteWHERE status='closed' AND occurred_at<'2026-01-01';Only after the SELECT result matches the retention rule should the DELETE use that same predicate.
Cascades expand lifecycle scope
Chapter 5 established that ON DELETE CASCADE is a business ownership rule. It means deleting the parent causes SQLite to delete referencing child rows when foreign keys are enabled.
PRAGMA foreign_keys=ON;DROP TABLE IF EXISTS child_note;DROP TABLE IF EXISTS parent_device;CREATE TABLE parent_device(device_id INTEGER PRIMARY KEY, code TEXT UNIQUE NOT NULL);CREATE TABLE child_note( note_id INTEGER PRIMARY KEY, device_id INTEGER NOT NULL REFERENCES parent_device(device_id) ON DELETE CASCADE, body TEXT NOT NULL);INSERT INTO parent_device VALUES (1,'PUMP-007'),(2,'FAN-014');INSERT INTO child_note(device_id,body) VALUES (1,'a'),(1,'b'),(2,'c');SELECT count(*) AS child_before FROM child_note; -- 3DELETE FROM parent_device WHERE device_id=1;SELECT changes() AS direct_parent_rows; -- 1SELECT count(*) AS child_after FROM child_note; -- 1The SQL changes() value reports direct rows changed by the top-level statement, not auxiliary rows changed by foreign-key actions. Therefore a count of 1 does not mean the operation affected only one stored row.
Hard delete versus soft delete
A hard delete removes the row. A soft-delete design usually keeps the row and records lifecycle state such as deleted_at or is_deleted. Neither is universally superior.
| Choice | Benefits | Costs / risks |
|---|---|---|
| Hard delete | Simple semantics; queries do not need hidden filters; can satisfy true erasure requirements. | Recovery requires backup/history; cascades can broaden scope. |
| Soft delete | Supports restore/audit workflows and references to historical entities. | Every query must understand visibility; UNIQUE rules may need partial indexes; data is not actually erased; child lifecycle becomes more complex. |
ALTER TABLE cleanup_note ADD COLUMN deleted_at TEXT;UPDATE cleanup_noteSET deleted_at=CURRENT_TIMESTAMPWHERE note_id=1;-- Application-visible rows must deliberately filter:SELECT * FROM cleanup_note WHERE deleted_at IS NULL;If users expect data to be erased, a soft-delete flag may be the wrong compliance behavior. If audit/history must be retained, immediate hard deletion may be wrong. Model the lifecycle from requirements.
Deleting rows does not necessarily shrink the file
SQLite stores tables and indexes in pages. Deleting rows can make pages reusable inside the database; it does not imply the operating-system file immediately becomes smaller. PRAGMA freelist_count reports currently unused pages in the database file.
PRAGMA page_count;PRAGMA freelist_count;DELETE FROM cleanup_note WHERE status='closed';PRAGMA page_count;PRAGMA freelist_count;For a tiny lab the values may not visibly change because pages still contain other records. On larger deletions, free pages can grow. Chapter 18 will cover maintenance and VACUUM choices. Do not schedule VACUUM merely because rows were deleted; it rewrites the database and has operational costs.
Transaction-protected cleanup lab
Build counts before and after the candidate deletion, then decide whether to commit.
DROP TABLE IF EXISTS retention_note;CREATE TABLE retention_note( note_id INTEGER PRIMARY KEY, status TEXT NOT NULL, occurred_at TEXT NOT NULL);INSERT INTO retention_note(status,occurred_at) VALUES('closed','2024-01-01'),('closed','2025-06-01'),('open','2024-02-01'),('open','2026-08-12');SELECT count(*) AS total_before FROM retention_note;SELECT count(*) AS candidatesFROM retention_noteWHERE status='closed' AND occurred_at<'2026-01-01'; -- expect 2BEGIN;DELETE FROM retention_noteWHERE status='closed' AND occurred_at<'2026-01-01';SELECT changes() AS direct_deleted; -- expect 2SELECT count(*) AS total_after_candidate FROM retention_note; -- expect 2-- Lab choice: verify then commit.COMMIT;SELECT count(*) AS final_total FROM retention_note; -- 2The important behavior is not the exact date rule; it is that candidate count, direct delete count, and final count agree with the intended lifecycle policy before COMMIT.
Failure cases and production judgment
| Failure | Diagnosis | Safer response |
|---|---|---|
| Missing WHERE | changes() equals a much larger scope than expected. | Keep operation inside a transaction and rollback before commit. |
| Unexpected cascade | Parent relationship declares CASCADE and FK enforcement is on. | Preview child counts and document ownership semantics. |
| Soft-deleted rows appear in reports | Queries forgot lifecycle filter. | Centralize visibility rules or reconsider soft delete. |
| File size did not shrink | Free pages remain reusable internally. | Measure page/freelist state; defer maintenance decisions to storage chapter. |
| DELETE count seems too small | changes() excludes FK cascade/trigger auxiliary changes. | Audit related tables and use lifecycle-specific counts. |
DELETE checkpoint
Reason about lifecycle, not just syntax.
- Why preview a DELETE predicate with SELECT?
- Can changes() tell you how many cascade child rows were deleted?
- Does soft delete erase data?
- Does deleting rows guarantee a smaller .db file immediately?
- When should VACUUM decisions be made?
Review the answers
Previewing exposes scope. changes() counts direct top-level changes, not foreign-key cascades. Soft delete retains the row. Deleted space is usually reusable inside the file rather than immediately returned to the OS. VACUUM should be chosen from measured storage/operational requirements, covered later.
Summary and bridge
Safe deletion combines target verification, transaction protection, relationship knowledge, and lifecycle intent. Next comes a different write problem: you have an incoming logical entity and need to insert it if new, but update or ignore it when a uniqueness rule identifies an existing row. That is UPSERT—not REPLACE.