UPDATE and DELETE — Modifying and Removing Rows

UPDATE and DELETE are powerful and dangerous operations — a missing WHERE clause updates or deletes every row in the table. This mistake has caused production outages at companies large and small. Beyond safety, understanding how to use UPDATE ... RETURNING to get modified rows back, how to update with subqueries, and how to implement soft delete (setting a deleted_at timestamp instead of removing the row) gives you the SQL tools needed for all FastAPI CRUD operations.

UPDATE

-- Update specific columns for specific rows (WHERE is essential!)
UPDATE users
SET role = 'editor'
WHERE id = 3;

-- Update multiple columns at once
UPDATE posts
SET
    title      = 'Updated Title',
    status     = 'published',
    updated_at = NOW()
WHERE id = 42;

-- Update with an expression
UPDATE posts
SET view_count = view_count + 1
WHERE id = 42;

-- Update based on another column's value
UPDATE users
SET is_active = FALSE
WHERE last_login_at < NOW() - INTERVAL '1 year';

-- Update multiple rows matching a condition
UPDATE posts
SET status = 'archived'
WHERE created_at < NOW() - INTERVAL '2 years'
  AND status = 'draft';
Note: PostgreSQL’s UPDATE finds rows matching the WHERE clause, modifies them, and returns the count of rows affected. Unlike some databases, PostgreSQL does not have an “UPDATE OR INSERT” syntax built into UPDATE itself — that is handled by INSERT … ON CONFLICT (the upsert pattern). After an UPDATE without RETURNING, the only feedback is the row count. With RETURNING, you get back the actual modified row data.
Tip: Always test your UPDATE’s WHERE clause with a SELECT first in psql before executing the UPDATE. Run SELECT * FROM posts WHERE id = 42 to verify it matches exactly the rows you intend to modify. When you are confident, change SELECT * to UPDATE posts SET ... WHERE id = 42. In production scripts, wrap the UPDATE in a transaction, verify the row count with RETURNING, and ROLLBACK if the count is unexpected.
Warning: UPDATE users SET role = 'banned' with no WHERE clause updates every user in the table to role ‘banned’. There is no undo unless you have a backup or are inside a transaction you have not yet committed. Many experienced engineers configure psql with set default_transaction_read_only = on; and only disable it when intentionally making changes, as a safety net against accidental mass modifications.

UPDATE … RETURNING

-- Get the updated row back in one query
UPDATE posts
SET
    title      = 'New Title',
    updated_at = NOW()
WHERE id = 42
RETURNING id, title, updated_at;

-- Verify the update was successful (returns empty if no rows matched)
UPDATE users
SET is_active = FALSE
WHERE id = 999
RETURNING id;
-- If no rows returned: user_id 999 did not exist

-- Return multiple updated rows
UPDATE posts
SET status = 'published', published_at = NOW()
WHERE author_id = 1 AND status = 'draft'
RETURNING id, title, published_at;

DELETE

-- Delete a specific row
DELETE FROM comments WHERE id = 15;

-- Delete with a condition
DELETE FROM posts WHERE status = 'draft' AND created_at < NOW() - INTERVAL '30 days';

-- Delete and return the deleted row(s)
DELETE FROM users WHERE id = 3
RETURNING id, email, name;
-- Confirms which row was deleted

-- Delete all rows in a table (faster than DELETE with no WHERE)
-- TRUNCATE removes all rows without scanning them — much faster for large tables
TRUNCATE TABLE sessions;

-- TRUNCATE with cascade (also truncates tables with FK references)
TRUNCATE TABLE users CASCADE;   -- also truncates posts, comments, etc.
-- WARNING: irreversible! Use only in development/test environments

Soft Delete Pattern

-- Instead of deleting rows, mark them as deleted (preserves audit trail)
-- Requires a deleted_at column (TIMESTAMPTZ, nullable)

-- Soft delete a post
UPDATE posts
SET deleted_at = NOW()
WHERE id = 42
RETURNING id, deleted_at;

-- Query active (not deleted) posts
SELECT id, title, status
FROM posts
WHERE deleted_at IS NULL   -- only non-deleted rows
  AND status = 'published'
ORDER BY created_at DESC;

-- Restore a soft-deleted post
UPDATE posts
SET deleted_at = NULL
WHERE id = 42;

-- Permanent hard delete (clean up old soft-deleted rows)
DELETE FROM posts
WHERE deleted_at < NOW() - INTERVAL '90 days';

-- Advantages of soft delete:
-- Recoverable: accidental deletion can be undone
-- Audit trail: know when and (with user_id) who deleted
-- Referential: child rows (comments) stay intact, queries can include them
-- 
-- Disadvantages:
-- Every query needs WHERE deleted_at IS NULL
-- Table grows without regular cleanup
-- Unique constraints need adjustment (unique email among active users)

Common Mistakes

Mistake 1 — UPDATE or DELETE without WHERE

❌ Wrong — modifies or deletes every row:

UPDATE users SET is_active = FALSE;   -- bans ALL users!
DELETE FROM posts;                     -- deletes ALL posts!

✅ Correct — always include WHERE:

UPDATE users SET is_active = FALSE WHERE id = 42;   -- ✓ specific row
DELETE FROM posts WHERE id = 42;                    -- ✓ specific row

Mistake 2 — Not using RETURNING to verify affected rows

❌ Wrong — cannot tell if the row existed:

UPDATE posts SET title = 'X' WHERE id = 9999;
-- Affected 0 rows silently — was post 9999 not found? Typo?

✅ Correct — RETURNING reveals if the row was found:

UPDATE posts SET title = 'X' WHERE id = 9999
RETURNING id;
-- Empty result set → post 9999 doesn't exist → return 404 in FastAPI ✓

Mistake 3 — Using TRUNCATE in production when DELETE is safer

❌ Wrong — TRUNCATE cannot be filtered and may cascade unexpectedly:

TRUNCATE TABLE users;   -- removes ALL users, cannot add WHERE clause!

✅ Correct — use DELETE with a WHERE clause for selective removal:

DELETE FROM users WHERE created_at > '2025-01-01' AND is_active = FALSE;   -- ✓

Quick Reference

Pattern SQL
Update one column UPDATE t SET col = val WHERE id = n
Update multiple columns UPDATE t SET c1 = v1, c2 = v2 WHERE ...
Increment UPDATE t SET count = count + 1 WHERE ...
Get updated row UPDATE ... RETURNING id, col, ...
Delete row DELETE FROM t WHERE id = n
Delete with return DELETE FROM t WHERE ... RETURNING *
Soft delete UPDATE t SET deleted_at = NOW() WHERE id = n
Clear table (dev only) TRUNCATE TABLE t

🧠 Test Yourself

A FastAPI PUT endpoint receives a request to update post id=42. After running the UPDATE, how do you return 404 if the post did not exist, and the updated post if it did?