Database Interview Prep
CRUD Operations

UPDATE Data

WHERE, Joins, RETURNING, and Atomic Counters

LinkedIn Hook

"A junior engineer ran UPDATE users SET email = 'test@example.com'; in production. Every single user. 4.2 million rows. The WHERE clause was on the next line of the editor -- but semicolons do not care what you meant."

UPDATE is the most dangerous statement in SQL. SELECT is read-only. INSERT adds new rows you can delete. DELETE at least sounds scary so people pay attention. UPDATE quietly rewrites history, and a missing WHERE clause turns a one-row fix into a company-wide incident.

But the blast radius is not the only trap. Non-atomic counters lose updates under load. Join-updates have completely different syntax in PostgreSQL and MySQL. CASE WHEN inside SET is the fastest way to do a thousand conditional updates in one statement -- and almost nobody uses it. And if you forget the RETURNING clause, you just lost your audit log.

In Lesson 3.3, I cover UPDATE from the basics to optimistic locking: the WHERE clause that must never be missing, UPDATE...FROM for PostgreSQL join updates, RETURNING for audit trails, CASE inside SET, and the atomic counter = counter + 1 pattern that prevents lost updates.

Read the full lesson -> [link]

#Database #SQL #PostgreSQL #MySQL #BackendDevelopment #InterviewPrep #DataEngineering


UPDATE Data thumbnail


What You'll Learn

  • Basic UPDATE ... SET ... WHERE syntax and why WHERE is not optional in practice
  • How to update multiple columns in a single statement
  • UPDATE ... FROM for join-updates in PostgreSQL and how MySQL's UPDATE ... JOIN differs
  • The RETURNING clause for audit logs and "fetch-on-write" patterns
  • Conditional updates with CASE WHEN inside SET to batch many changes into one statement
  • Atomic counter patterns (counter = counter + 1) that prevent lost updates
  • Preview of concurrency control and optimistic locking with a version column

The Spreadsheet Analogy -- Editing One Cell vs the Whole Column

Imagine a spreadsheet with 4 million rows. You want to fix one customer's email. You click the cell in row 42, type the new value, press Enter. Done. One cell changed, everything else untouched.

Now imagine you select the entire column header by accident, type the new value, and press Enter. Every row in that column now holds the same value. There is no undo after you save. That is exactly what UPDATE users SET email = 'x' does without a WHERE clause: it targets the entire column of every row.

The WHERE clause is the click on row 42. Without it, you selected the header. SQL does not warn you. It does not ask "are you sure?" It runs the statement, reports "4,213,887 rows affected," and commits.

+---------------------------------------------------------------+
|           UPDATE WITHOUT WHERE                                |
+---------------------------------------------------------------+
|                                                                |
|  UPDATE users SET email = 'test@example.com';                 |
|                                                                |
|  Row 1:  alice@a.com    -> test@example.com                    |
|  Row 2:  bob@b.com      -> test@example.com                    |
|  Row 3:  carol@c.com    -> test@example.com                    |
|  ...                                                           |
|  Row N:  zack@z.com     -> test@example.com                    |
|                                                                |
|  Result: 4,213,887 rows rewritten. No undo. Restore backup.    |
|                                                                |
+---------------------------------------------------------------+

+---------------------------------------------------------------+
|           UPDATE WITH WHERE                                    |
+---------------------------------------------------------------+
|                                                                |
|  UPDATE users SET email = 'test@example.com' WHERE id = 42;   |
|                                                                |
|  Row 42: alice@a.com    -> test@example.com                    |
|                                                                |
|  Result: 1 row updated. Surgical. Safe. Auditable.             |
|                                                                |
+---------------------------------------------------------------+

Basic UPDATE -- SET and WHERE

The anatomy of an UPDATE is three parts: the target table, the SET clause describing the new values, and the WHERE clause describing which rows to touch. Miss any of those pieces and the behaviour is very different.

-- Schema we will use throughout the lesson
CREATE TABLE users (
  id          BIGSERIAL PRIMARY KEY,
  email       TEXT        NOT NULL UNIQUE,
  full_name   TEXT        NOT NULL,
  status      TEXT        NOT NULL DEFAULT 'active',
  login_count INTEGER     NOT NULL DEFAULT 0,
  version     INTEGER     NOT NULL DEFAULT 1,
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

INSERT INTO users (email, full_name) VALUES
  ('alice@example.com', 'Alice Lee'),
  ('bob@example.com',   'Bob Singh'),
  ('carol@example.com', 'Carol Diaz');

-- Simplest update: one row, one column, targeted by primary key
UPDATE users
SET    full_name = 'Alice Park'
WHERE  id = 1;
-- Output: UPDATE 1
-- (PostgreSQL reports the number of rows affected after every UPDATE.)

The row count after each statement is your first line of defence. If you expected to touch one row and the server says UPDATE 4213887, you have a problem -- hopefully inside a transaction you have not yet committed.

The Scary Anecdote -- Why DBAs Wrap Everything in Transactions

A real story from a production incident post-mortem. An engineer needed to set a single user's status to suspended. They opened the editor, typed:

UPDATE users SET status = 'suspended'
WHERE id = 98472;

Then their cursor was on the WHERE line, they hit Ctrl+A to select the line to copy it, then accidentally hit Ctrl+Enter which their IDE had bound to "run selection." The IDE sent only the selected WHERE line to the server, which responded with a syntax error. Confused, the engineer pressed Ctrl+Enter again without a selection -- which runs the entire file. But by then they had already backspaced the WHERE clause while trying to fix the error. The server happily ran UPDATE users SET status = 'suspended' against 12 million rows. Every customer was logged out. The on-call rotation was woken up. The company lost four hours of revenue.

The fix is a habit, not a tool: always type UPDATE statements inside an explicit transaction, verify the row count, and only commit when it matches your expectation.

BEGIN;

UPDATE users
SET    status = 'suspended'
WHERE  id = 98472;
-- Output: UPDATE 1   <- sanity check the count BEFORE committing

-- If the number is wrong, ROLLBACK. No harm done.
-- ROLLBACK;

COMMIT;

Updating Multiple Columns in One Statement

The SET clause accepts a comma-separated list. Always update every changing column in a single statement rather than firing three UPDATEs in a row -- it is one round trip, one WAL record, and one transaction.

-- Update three columns atomically, and bump updated_at to now()
UPDATE users
SET    full_name  = 'Alice Park',
       email      = 'alice.park@example.com',
       updated_at = now()
WHERE  id = 1;
-- Output: UPDATE 1

You can also use the row-value form when you have many columns driven by a subquery or tuple:

-- Row-value form: useful when values come from a subquery or VALUES list
UPDATE users
SET    (full_name, email, updated_at) =
       ('Alice Park', 'alice.park@example.com', now())
WHERE  id = 1;
-- Output: UPDATE 1

UPDATE ... FROM -- Join Updates in PostgreSQL

Often you need to update rows in one table based on matching rows in another. PostgreSQL uses the UPDATE ... FROM form: you list the additional tables in the FROM clause and correlate them in the WHERE clause. MySQL uses a completely different UPDATE ... JOIN syntax. They produce the same logical result but the statements are not portable.

-- Staging table with corrected names from the CRM import
CREATE TABLE name_corrections (
  user_id   BIGINT PRIMARY KEY,
  full_name TEXT   NOT NULL
);

INSERT INTO name_corrections VALUES
  (1, 'Alice Park-Lee'),
  (2, 'Robert Singh'),
  (3, 'Carolina Diaz');

-- PostgreSQL: UPDATE ... FROM
UPDATE users u
SET    full_name  = c.full_name,
       updated_at = now()
FROM   name_corrections c
WHERE  u.id = c.user_id;
-- Output: UPDATE 3

-- Verify
SELECT id, full_name FROM users ORDER BY id;
-- Output:
--  id |    full_name
-- ----+-----------------
--   1 | Alice Park-Lee
--   2 | Robert Singh
--   3 | Carolina Diaz

MySQL -- UPDATE ... JOIN

MySQL and MariaDB do not support UPDATE ... FROM. The equivalent is a multi-table UPDATE with an explicit JOIN in the UPDATE clause itself:

-- MySQL syntax (DOES NOT work in PostgreSQL)
UPDATE users u
JOIN   name_corrections c ON c.user_id = u.id
SET    u.full_name  = c.full_name,
       u.updated_at = NOW();
-- Output: Rows matched: 3  Changed: 3  Warnings: 0
+---------------------------------------------------------------+
|           PostgreSQL vs MySQL JOIN UPDATES                    |
+---------------------------------------------------------------+
|                                                                |
|  PostgreSQL:                                                   |
|    UPDATE users u                                              |
|    SET    name = c.name                                        |
|    FROM   name_corrections c                                   |
|    WHERE  u.id = c.user_id;                                    |
|                                                                |
|  MySQL / MariaDB:                                              |
|    UPDATE users u                                              |
|    JOIN   name_corrections c ON c.user_id = u.id               |
|    SET    u.name = c.name;                                     |
|                                                                |
|  SQL Server:                                                   |
|    UPDATE u                                                    |
|    SET    name = c.name                                        |
|    FROM   users u                                              |
|    JOIN   name_corrections c ON c.user_id = u.id;              |
|                                                                |
+---------------------------------------------------------------+

Cross-dialect migration is one of the main reasons teams reach for an ORM or query builder -- join-update syntax diverges more than almost any other statement in the SQL standard.


RETURNING -- Fetching Updated Rows in One Round Trip

PostgreSQL's RETURNING clause makes UPDATE return the updated rows as if it were a SELECT. This is a massive win for audit logs, "fetch-after-update" patterns in APIs, and anywhere you otherwise would have run a second SELECT to see what changed.

-- Update and return the new row -- no second round trip needed
UPDATE users
SET    status     = 'suspended',
       updated_at = now()
WHERE  id = 2
RETURNING id, email, status, updated_at;
-- Output:
--  id |      email      |  status   |          updated_at
-- ----+-----------------+-----------+-------------------------------
--   2 | bob@example.com | suspended | 2026-04-13 10:15:42.812345+00

You can return the old values using a self-join with the system snapshot, but the idiomatic audit pattern is to return both old and new with a CTE:

-- Capture before/after for an audit log in a single statement
WITH before AS (
  SELECT id, status FROM users WHERE id = 3 FOR UPDATE
),
updated AS (
  UPDATE users
  SET    status = 'archived', updated_at = now()
  WHERE  id = 3
  RETURNING id, status AS new_status
)
INSERT INTO users_audit (user_id, old_status, new_status, changed_at)
SELECT b.id, b.status, u.new_status, now()
FROM   before b
JOIN   updated u ON u.id = b.id;
-- Output: INSERT 0 1

MySQL note: RETURNING arrived in MariaDB 10.5 and is still absent from stock MySQL (as of 8.x). In MySQL you need a SELECT before or after the UPDATE, ideally inside the same transaction with FOR UPDATE to avoid races.


Conditional Updates with CASE WHEN

CASE WHEN inside the SET clause lets one statement apply different values to different rows based on a condition. This is the fastest way to process many small updates -- instead of N round trips, you send one.

-- Promote active users to gold, suspend inactive ones, leave others alone
UPDATE users
SET    status = CASE
         WHEN login_count >= 100 THEN 'gold'
         WHEN login_count  =   0 THEN 'suspended'
         ELSE status                      -- leave unchanged
       END,
       updated_at = now()
WHERE  status <> 'archived';
-- Output: UPDATE 3

The ELSE status branch is the key trick: it tells PostgreSQL to leave the column untouched for rows that match the WHERE clause but no CASE branch. Without it, non-matching rows would have status set to NULL.

You can drive many column updates from a VALUES list to batch hundreds of heterogeneous changes in one statement:

-- Batch update with per-row values using a VALUES list + UPDATE FROM
UPDATE users u
SET    full_name  = v.full_name,
       status     = v.status,
       updated_at = now()
FROM  (VALUES
         (1, 'Alice P.',   'gold'),
         (2, 'Bob S.',     'suspended'),
         (3, 'Carol D.',   'active')
      ) AS v(id, full_name, status)
WHERE  u.id = v.id;
-- Output: UPDATE 3

One round trip, one WAL flush, one index pass. This pattern scales to thousands of rows per statement comfortably.


Atomic Counters -- Why counter = counter + 1 Matters

The single most common UPDATE bug in web applications is a non-atomic counter increment. It looks innocent:

// DO NOT DO THIS -- lost-update bug under concurrency
const { rows } = await db.query('SELECT login_count FROM users WHERE id = $1', [id]);
const next = rows[0].login_count + 1;
await db.query('UPDATE users SET login_count = $1 WHERE id = $2', [next, id]);

Two concurrent requests both read login_count = 5, both compute 6, and both write 6. One increment is silently lost. The user has logged in twice but the counter only advanced once. With enough traffic you can lose hundreds of increments per minute.

The fix is one statement that reads and writes atomically:

-- Atomic increment -- the database does the read, add, and write as one op
UPDATE users
SET    login_count = login_count + 1,
       updated_at  = now()
WHERE  id = 1
RETURNING login_count;
-- Output:
--  login_count
-- -------------
--            6

Under the hood, PostgreSQL takes a row-level lock on the target row, reads the current value, computes the new one, writes it, and releases the lock -- all within the same statement. Two concurrent statements serialize, and both increments land. No lost updates, no explicit locking, no retry loop.

+---------------------------------------------------------------+
|           NON-ATOMIC vs ATOMIC COUNTER                        |
+---------------------------------------------------------------+
|                                                                |
|  NON-ATOMIC (SELECT then UPDATE):                              |
|                                                                |
|   T1: SELECT count -> 5                                        |
|   T2: SELECT count -> 5                                        |
|   T1: UPDATE set 6                                             |
|   T2: UPDATE set 6        <- LOST UPDATE                       |
|                                                                |
|  ATOMIC (UPDATE count = count + 1):                            |
|                                                                |
|   T1: UPDATE +1 -> row lock -> 6                               |
|   T2: UPDATE +1 -> waits for T1 -> 7                           |
|                                                                |
|  Same pattern works for: balance = balance - $amount,          |
|  inventory = inventory - 1, retries = retries + 1.             |
|                                                                |
+---------------------------------------------------------------+

This same pattern is the foundation of inventory decrement, balance debits, and rate-limit buckets. Any time you need to read a number, change it, and write it back, let the database do all three in one statement.


Napkin Visual Prompt

Napkin AI Visual Prompt: "Dark gradient background (#0a0f1f -> #111a2e). Split diagram showing two concurrent transactions T1 and T2 racing on a counter. LEFT panel labeled 'Non-atomic' in rose (#ff5c8a) shows SELECT then UPDATE with a red X over the lost increment. RIGHT panel labeled 'Atomic' in sky blue (#4fc3f7) shows UPDATE SET count = count + 1 with a lock icon and two successful increments. White monospace labels for each step. Arrows showing time flowing downward. Subtle grid overlay."


Concurrency Preview -- Optimistic Locking with a Version Column

Atomic increments work because the change commutes: +1 then +1 equals +1 regardless of order. But many updates are not commutative -- editing a user's name, updating an order's shipping address, changing a product's price. For those, you need a way to detect that the row changed under you.

The simplest technique is optimistic locking with a version column. Every row has a version integer. Every UPDATE matches on the version you last read, and increments it. If the version has changed, zero rows match and the UPDATE reports 0 -- your code detects the conflict and retries or surfaces an error to the user.

-- Read the row along with its current version
SELECT id, full_name, version FROM users WHERE id = 1;
-- Output:
--  id | full_name  | version
-- ----+------------+---------
--   1 | Alice Park |       1

-- Later, save the edit. The version = 1 clause is the optimistic check.
UPDATE users
SET    full_name  = 'Alice Park-Lee',
       version    = version + 1,
       updated_at = now()
WHERE  id = 1
  AND  version = 1
RETURNING version;
-- Output:
--  version
-- ---------
--        2
-- (UPDATE 1)

-- If another session had already saved a change, version would now be 2
-- and this statement would return UPDATE 0 -- the application layer
-- then knows to reload and retry.
+---------------------------------------------------------------+
|           OPTIMISTIC LOCKING FLOW                             |
+---------------------------------------------------------------+
|                                                                |
|   Client A reads row (version=1)                               |
|   Client B reads row (version=1)                               |
|   Client A UPDATE ... WHERE version=1 -> version=2   OK        |
|   Client B UPDATE ... WHERE version=1 -> UPDATE 0    CONFLICT  |
|   Client B reloads row (version=2) and retries                 |
|                                                                |
+---------------------------------------------------------------+

This is a preview -- Chapter 11 covers transactions, isolation levels, MVCC, and pessimistic vs optimistic locking in depth. For now, remember the rule: if your UPDATE is not commutative, match on a version column to detect concurrent edits.


Common Mistakes

1. Missing WHERE clause. The cardinal sin. UPDATE users SET status = 'x' rewrites every row in the table. Always type the WHERE clause first, or wrap UPDATEs in an explicit BEGIN; ... COMMIT; block so you can verify the row count before committing. Consider enabling a safety feature like MySQL's --safe-updates in your client, or adding a rule that rejects WHERE-less UPDATEs in production.

2. Non-atomic counter via SELECT-then-UPDATE. Reading a counter into the application, adding one, and writing it back is a textbook lost-update bug. Under any concurrency, increments silently vanish. Always express counter changes as SET counter = counter + 1 in a single UPDATE so the database handles the read-modify-write atomically. The same rule applies to balance debits, inventory decrements, and rate-limit buckets.

3. Updating the primary key. Changing a primary key cascades through every foreign key (if ON UPDATE CASCADE is set), invalidates cached row IDs, and breaks any external system that holds the old ID. If you think you need to update a PK, you almost certainly need a new row with a new ID and a migration plan for the references. Use a surrogate key (BIGSERIAL / UUID) that has no business meaning and leave it alone.

4. Forgetting RETURNING for audit logs. Running an UPDATE then a SELECT to see what changed is two round trips and a race condition -- the row could change between the UPDATE and the SELECT. Use RETURNING to get the post-update values in the same statement, and capture the pre-update values in a CTE if you need both. For compliance-heavy tables, pair this with a trigger that writes to an audit table automatically.

5. Lost updates without isolation or optimistic locking. Concurrent edits to the same row by two users -- form A saves "Alice," form B saves "Alicia" -- and whichever commits last silently overwrites the other. At READ COMMITTED (the default in PostgreSQL and MySQL InnoDB) this is not prevented automatically. Either use SELECT ... FOR UPDATE to take a pessimistic lock, or add a version column and match on it in the UPDATE. Chapter 11 goes deep on isolation levels and why REPEATABLE READ alone is not enough.


Interview Questions

1. "What is the difference between UPDATE ... FROM in PostgreSQL and UPDATE ... JOIN in MySQL?"

Both produce join-updates -- they modify rows in one table based on matching rows in another -- but the syntax is different enough that statements are not portable between the two dialects. In PostgreSQL, you write UPDATE target SET col = src.col FROM other_table src WHERE target.id = src.id. The additional table goes in a FROM clause, correlated to the target in the WHERE clause. In MySQL and MariaDB, UPDATE ... FROM does not exist; instead you write UPDATE target JOIN other_table src ON src.id = target.id SET target.col = src.col. The join is expressed inside the UPDATE itself, before the SET. SQL Server uses yet a third variant -- UPDATE alias SET ... FROM target alias JOIN other ON .... The logical result is the same in all three, but migrating between databases requires rewriting every join-update. This is one of the main reasons teams reach for ORMs or query builders that abstract the dialect differences.

2. "Why is UPDATE counter SET value = value + 1 safer than reading the value and writing it back from the application?"

Because it is atomic at the database level. The SELECT-then-UPDATE pattern has a classic lost-update race: two concurrent requests both read value = 5, both compute 6, and both write 6. One of the increments vanishes silently -- the user pressed the button twice, but the counter only went up by one. The atomic version, UPDATE counter SET value = value + 1 WHERE id = 1, lets the database take a row-level lock, read the current value, add one, and write the new value as a single indivisible operation. Concurrent statements serialize on the row lock and both increments land: 5 -> 6 -> 7. No explicit transactions, no retry loops, no lost updates. The same pattern works for any commutative operation: balance debits, inventory decrements, retry counters, rate-limit buckets. Any time you find yourself reading a number into the application to compute a new number, ask whether you can express it as an expression in SET instead.

3. "What does the RETURNING clause do, and why is it useful for audit logs?"

RETURNING is a PostgreSQL extension (also supported in MariaDB 10.5+ and SQLite 3.35+, but not in stock MySQL) that turns an INSERT, UPDATE, or DELETE into something that also acts like a SELECT -- it returns the affected rows as a result set. For UPDATE, you can return the new values of every column, computed expressions, or even the entire row. This eliminates the second round trip you would otherwise need to see what changed, and it removes the race condition where the row could change between the UPDATE and a follow-up SELECT. For audit logging, the idiomatic pattern is to wrap the UPDATE in a CTE, capture the old values via a SELECT ... FOR UPDATE in another CTE, and insert a combined before/after record into an audit table in the same statement. The entire operation is one round trip, one transaction, and one WAL flush. For compliance-heavy tables, you can combine RETURNING with a row-level trigger that writes to an audit table automatically, giving you both the application-level audit trail and a database-enforced guarantee that nothing bypasses it.

4. "Explain optimistic locking with a version column. When would you use it over SELECT FOR UPDATE?"

Optimistic locking is a concurrency control strategy where you assume conflicts are rare and detect them at write time rather than preventing them at read time. Every row has a version integer; every UPDATE includes WHERE version = ? with the version the client last read, and sets version = version + 1. If another session modified the row in the meantime, its UPDATE already incremented the version, so your UPDATE matches zero rows and returns UPDATE 0. The application layer sees the zero count, knows there was a conflict, and either retries automatically or shows the user a "this record was changed by someone else, reload and try again" message. You use optimistic locking when conflicts are rare (most user-edit workflows, admin panels, CMS editors), when the read-to-write gap is long (a user opens a form, fills it out for five minutes, then saves), or when you cannot hold a database transaction open across a user's think time. SELECT FOR UPDATE is pessimistic -- it takes a row lock at read time and holds it until the transaction commits. It is the right choice when conflicts are frequent, when the critical section is short (a few milliseconds), and when you cannot tolerate a retry loop -- for example, debiting an account balance inside a single request handler. Optimistic locking scales better under low contention; pessimistic locking is simpler to reason about under high contention.

5. "You need to update 10,000 user rows with different status values based on their activity. How would you do it in one statement instead of 10,000 UPDATEs?"

Two idiomatic approaches, depending on how the new values are computed. If the new values follow a simple rule based on existing columns, use a single UPDATE with CASE WHEN inside SET: UPDATE users SET status = CASE WHEN login_count >= 100 THEN 'gold' WHEN login_count = 0 THEN 'suspended' ELSE status END. The ELSE status branch is the trick -- it leaves rows unchanged instead of setting them to NULL, so you can use a broad WHERE clause without worrying about collateral damage. If the new values come from an external list (a CSV import, a CRM sync, a batch job), load them into a temporary or staging table and use a join-update: UPDATE users u SET status = s.status FROM staging s WHERE u.id = s.user_id. In PostgreSQL you can also inline the values directly with a VALUES list as a subquery: UPDATE users u SET status = v.status FROM (VALUES (1, 'gold'), (2, 'suspended'), ...) AS v(id, status) WHERE u.id = v.id. Either way, you get one round trip, one WAL record, one transaction, and one index pass -- orders of magnitude faster than 10,000 individual UPDATEs. The single biggest win in most batch jobs is collapsing per-row UPDATEs into a single set-based statement.


Quick Reference -- UPDATE Cheat Sheet

+---------------------------------------------------------------+
|           UPDATE CHEAT SHEET                                  |
+---------------------------------------------------------------+
|                                                                |
|  BASIC:                                                        |
|  UPDATE t SET col = val WHERE id = ?;                          |
|                                                                |
|  MULTIPLE COLUMNS:                                             |
|  UPDATE t SET a = ?, b = ?, updated_at = now() WHERE id = ?;   |
|                                                                |
|  JOIN UPDATE (PostgreSQL):                                     |
|  UPDATE t SET col = s.col FROM src s WHERE t.id = s.id;        |
|                                                                |
|  JOIN UPDATE (MySQL):                                          |
|  UPDATE t JOIN src s ON s.id = t.id SET t.col = s.col;         |
|                                                                |
|  RETURNING (fetch after write):                                |
|  UPDATE t SET ... WHERE id = ? RETURNING *;                    |
|                                                                |
|  CONDITIONAL (CASE WHEN):                                      |
|  UPDATE t SET col = CASE WHEN x THEN 'a' ELSE col END;         |
|                                                                |
|  ATOMIC COUNTER:                                               |
|  UPDATE t SET n = n + 1 WHERE id = ? RETURNING n;              |
|                                                                |
|  OPTIMISTIC LOCK:                                              |
|  UPDATE t SET ..., version = version + 1                       |
|  WHERE id = ? AND version = ?;                                 |
|                                                                |
+---------------------------------------------------------------+

+---------------------------------------------------------------+
|           KEY RULES                                            |
+---------------------------------------------------------------+
|                                                                |
|  1. Never run UPDATE without WHERE in production               |
|  2. Wrap destructive UPDATEs in BEGIN ... COMMIT               |
|  3. Verify the row count before committing                     |
|  4. Use counter = counter + 1 for atomic increments            |
|  5. Use RETURNING to avoid second-round-trip SELECTs           |
|  6. Use CASE WHEN in SET to batch many updates in one          |
|  7. Never update a primary key -- use a surrogate              |
|  8. Use a version column for non-commutative concurrent edits  |
|                                                                |
+---------------------------------------------------------------+
ConcernWrong WayRight Way
Updating one rowNo WHERE clauseWHERE id = ? inside a transaction
Counter incrementSELECT then UPDATESET n = n + 1 atomically
Join update (PG)Subquery per rowUPDATE ... FROM other
Join update (MySQL)UPDATE ... FROMUPDATE ... JOIN
Audit trailSecond SELECTRETURNING + CTE
Many conditional updatesN round tripsCASE WHEN inside SET
Concurrent editsLast write winsversion column + retry
Primary key changeUPDATE SET id = ?New row + migrate refs

Prev: Lesson 3.2 -- Select Data Next: Lesson 3.4 -- Delete Data


This is Lesson 3.3 of the Database Interview Prep Course -- 12 chapters, 58 lessons.

On this page