UPDATE modifies rows that already exist. It's the most dangerous statement you've seen so far, and the reason is arithmetic: if you get an INSERT wrong you create one extra row and you delete it; if you get an UPDATE wrong you overwrite correct data with incorrect data and the previous value stops existing. There's no recycle bin. There's no "undo". There's only last night's backup, if there is one.
That's why this lesson devotes its first half to something that isn't syntax: the professional protocol for running an UPDATE without wrecking anything. Write the SELECT first, count the rows, wrap it in a transaction, check, and only then commit. It's a thirty-second habit separating someone who has spent years touching databases from someone who has spent a month. After that will come everything UPDATE can do: several columns at once, calculations over the previous value, updates driven by another table with FROM, RETURNING to see exactly what you've changed, and the property —idempotence— that determines whether a script can be rerun without fear.
⚠️ Safety warning
UPDATEis a destructive operation: it replaces existing data without keeping the previous value.
- Run all of this lesson's examples on your practice database (
greenstore), never on a production system.- Take a backup beforehand ahead of any test:
pg_dump -U sql_course -d greenstore -f backup.sql. Getting back to the initial state is as easy as runninggreenstore.sqlagain.- On a real system, an
UPDATEover live data should be reviewed by another person and run inside a transaction.
Contents
- The syntax, and the
WHEREas a safety net - What exactly happens if you forget the
WHERE - The five-step protocol
BEGIN…ROLLBACK/COMMIT: the real net- Updating several columns at once
- Computing from the previous value: raising prices by 5 %
- Why the order of the assignments doesn't matter
UPDATE ... FROM: updating from another tableRETURNING: seeing what you've changed- An
UPDATEthat violates a constraint - Updating to
NULL, and the effect ofON UPDATE CASCADE - Idempotence: why it matters when a script is rerun
- Four GreenStore business cases
- Common Mistakes and Tips
- Exercises
- Conclusion
- The syntax, and the
WHERE as a safety net
WHERE as a safety netThree pieces: which table, which columns change and to what value, and which rows. Only the third is optional for the engine; none of them is optional for you.
A minimal example: product 13 (Soy wax candles) has been sitting at stock 0 for months and the decision is to discontinue it.
UPDATE N tells you how many rows it modified. That number is your first line of defence: if you were expecting one row and it says UPDATE 17, something has gone very wrong — but at least you know.
One nuance about that counter: PostgreSQL counts the rows that matched the WHERE, not the ones whose value changed. If you update a column to the value it already had, the row counts all the same:
One row "modified" even though the stock was already 0. It's important to know: UPDATE 1 means "one row matched the condition", not "one row changed".
And the critical point, already flagged at the end of module 4: in the logical execution order, an UPDATE's WHERE does exactly the same job as a SELECT's. It determines the set of affected rows.
flowchart LR
A["1 · FROM<br/>the table (and the extra FROM)"] --> B["2 · WHERE<br/>selects the ROWS<br/>that will be touched"]
B --> C["3 · SET<br/>computes the new values<br/>from the old ones"]
C --> D["4 · constraints<br/>NOT NULL · CHECK · UNIQUE · FK"]
D --> E["5 · RETURNING<br/>(optional)"]
That's the reason module 5 is the natural continuation of module 4: the WHERE you've spent four modules sharpening is the same one. What changes is the consequence of getting it wrong.
- What exactly happens if you forget the
WHERE
WHEREThere's no mystery: the UPDATE applies to every row of the table.
All twenty products in the catalogue have just gone up by 5 %. Not just the Drinks: all of them. And the previous price no longer exists anywhere.
| id | name | price |
|---|---|---|
| 1 | Extra virgin olive oil 500 ml | 13.13 |
| 2 | Organic brown rice 1 kg | 4.10 |
| 3 | Raw orange blossom honey 500 g | 10.24 |
| 4 | Spelt pasta 500 g | 2.94 |
| 5 | Organic crushed tomato 400 g | 2.05 |
(The first 5 of 20 rows.)
And here's the truly insidious part: PostgreSQL hasn't complained. No error, no warning, no confirmation. The statement is syntactically perfect and semantically legal. The engine did exactly what you asked. The only clue is that UPDATE 20 instead of UPDATE 4, and to see it you have to be looking.
Now picture the same statement on a products table of 40,000 references in a real shop, at seven o'clock on a Friday evening. It isn't a made-up story: it's one of the industry's most repeated incidents.
Why SQL doesn't protect you. Some graphical clients warn you if they detect an
UPDATEor aDELETEwith noWHERE, andpsqleven has an option for it (\set ON_ERROR_STOP ondoesn't do this, but tools like pgcli or DBeaver do). But the SQL standard doesn't provide for it: anUPDATEwith noWHEREis a legitimate operation —sometimes it's exactly what you want, for instance when filling in a new column—. The protection has to come from your process, not from the language.
- The five-step protocol
This is the habit. Five steps, thirty seconds, zero incidents.
The task: "raise the price of every Drinks product by 5 %".
Step 1 — Write the SELECT with the final WHERE
Before writing the word UPDATE, write the query that locates exactly the rows you want to touch.
| id | name | category_id | price |
|---|---|---|---|
| 14 | Organic chamomile tea 20 bags | 4 | 3.25 |
| 15 | Ceremonial matcha green tea 30 g | 4 | 22.00 |
| 16 | Ginger kombucha 750 ml | 4 | 4.95 |
| 17 | Cold-pressed orange juice 1 L | 4 | 5.40 |
Step 2 — Count the rows and compare with what you expected
| affected_rows |
|---|
| 4 |
Four. It matches what we expected (Drinks has 4 products, as you checked in 04-06). If the number surprises you, stop. That surprise is the most valuable signal you're going to get.
Step 3 — Preview the new values
Write the SET expression as a calculated column of the SELECT and look at them:
SELECT id,
name,
price AS current_price,
ROUND(price * 1.05, 2) AS new_price,
ROUND(price * 0.05, 2) AS increase
FROM products
WHERE category_id = 4
ORDER BY id;| id | name | current_price | new_price | increase |
|---|---|---|---|---|
| 14 | Organic chamomile tea 20 bags | 3.25 | 3.41 | 0.16 |
| 15 | Ceremonial matcha green tea 30 g | 22.00 | 23.10 | 1.10 |
| 16 | Ginger kombucha 750 ml | 4.95 | 5.20 | 0.25 |
| 17 | Cold-pressed orange juice 1 L | 5.40 | 5.67 | 0.27 |
Every price is reasonable. No odd rounding, no negatives, no surprises.
Step 4 — Turn the SELECT into an UPDATE without touching the WHERE
Copy the WHERE literally. Don't rewrite it from memory: copy it.
Four, the same number as in step 2. If it had said UPDATE 20, you'd know instantly that the WHERE had got lost along the way.
Step 5 — Verify
| id | name | price |
|---|---|---|
| 14 | Organic chamomile tea 20 bags | 3.41 |
| 15 | Ceremonial matcha green tea 30 g | 23.10 |
| 16 | Ginger kombucha 750 ml | 5.20 |
| 17 | Cold-pressed orange juice 1 L | 5.67 |
Identical to step 3's preview.
A detail about types worth understanding. We didn't write
ROUND(...)in theUPDATEand even so the prices came out with two decimals. The reason is thatpriceisNUMERIC(10,2): when assigning3.25 * 1.05 = 3.4125to that column, PostgreSQL rounds on storing (3.41). It works, but it's implicit rounding. If the result matters to you —and with money it always matters—, write the explicitROUNDso whoever reads your code knows the decision was yours and not the type system's.
BEGIN … ROLLBACK / COMMIT: the real net
BEGIN … ROLLBACK / COMMIT: the real netThe previous section's protocol reduces the risk enormously, but it doesn't remove it. The complete safety net is the transaction.
In PostgreSQL, each standalone statement runs and commits on its own (autocommit). With BEGIN you open a block where nothing is definitive until you say COMMIT, and where ROLLBACK undoes it all:
Now, before committing, verify:
| id | name | price |
|---|---|---|
| 14 | Organic chamomile tea 20 bags | 3.41 |
| 15 | Ceremonial matcha green tea 30 g | 23.10 |
| 16 | Ginger kombucha 750 ml | 5.20 |
| 17 | Cold-pressed orange juice 1 L | 5.67 |
If it's right:
And if it's wrong —or if that UPDATE 4 had been an UPDATE 20—:
| id | name | price |
|---|---|---|
| 14 | Organic chamomile tea 20 bags | 3.25 |
| 15 | Ceremonial matcha green tea 30 g | 22.00 |
| 16 | Ginger kombucha 750 ml | 4.95 |
| 17 | Cold-pressed orange juice 1 L | 5.40 |
As if it had never happened. That's the power you've gone four modules without using.
The complete flow, which from here on you should apply whenever you touch data:
flowchart TD
A["SELECT with the WHERE<br/>and count rows"] --> B{"Is the number<br/>the expected one?"}
B -->|No| A
B -->|Yes| C["BEGIN"]
C --> D["UPDATE"]
D --> E{"Does UPDATE N match<br/>the count?"}
E -->|No| F["ROLLBACK"]
E -->|Yes| G["Verification SELECT"]
G --> H{"Is the data<br/>correct?"}
H -->|No| F
H -->|Yes| I["COMMIT"]
F --> A
Three practical warnings about transactions, just enough to use them today:
| Warning | Detail |
|---|---|
| An open transaction locks rows | Until you COMMIT or ROLLBACK, the rows you've touched stay locked for other sessions. Don't go to lunch with a BEGIN open |
| An error aborts the whole transaction | If a statement fails inside the block, PostgreSQL enters state 25P02 and rejects everything until you ROLLBACK. The message is current transaction is aborted, commands ignored until end of transaction block |
| Sequences aren't undone | As you saw in 05-02, a ROLLBACK doesn't give back the ids consumed |
This is only the tip. The complete model of transactions —the ACID properties, the isolation levels, the locks and the deadlocks— is the whole of module 9. Here you use it as a working tool: open, check, commit or undo. With that alone you're far safer than you were ten minutes ago.
And an alternative when a transaction isn't enough, because the change is large or irreversible: make a copy of the table first.
It's the legitimate use of CTAS 05-01 announced. Remember it doesn't copy constraints (05-01, section 8): it's good for restoring values, not for replacing the table.
- Updating several columns at once
They're separated by commas inside the same SET:
| id | name | price | stock | active |
|---|---|---|---|---|
| 20 | Spirulina capsules 120 units | 16.40 | 0 | false |
A single UPDATE with two assignments is always better than two UPDATEs, and not just because you type less:
Two separate UPDATEs |
One with two assignments | |
|---|---|---|
| Passes over the table | 2 | 1 |
| Row versions created | 2 | 1 |
| Visible intermediate state | Yes: between the two, the row is half done | No |
Atomicity with no BEGIN |
Not guaranteed | Guaranteed |
That "visible intermediate state" is the weighty argument: between the first UPDATE and the second there's an instant in which the product is inactive but with 55 units of stock. Another session can read it right there.
- Computing from the previous value: raising prices by 5 %
On the right-hand side of the = you can use any expression, the row's own columns included:
UPDATE products SET price = price * 1.05 WHERE category_id = 4; -- raise by 5 %
UPDATE products SET stock = stock + 50 WHERE id = 13; -- restock 50 units
UPDATE orders SET shipping_cost = 0 WHERE shipping_cost < 5; -- free shippingThe rule, and it's the key to the whole of the next section: the right-hand side is evaluated with the values the row had BEFORE the UPDATE.
An example with CASE… well, not with CASE yet (module 6). An example with arithmetic and a column from another row of the same table won't do either: that's a subquery (module 7). With what you have today, the useful expressions are 02-02's: arithmetic, concatenation and basic functions.
SELECT id, name, cost, price, ROUND((price - cost) / price, 4) AS relative_margin
FROM products WHERE category_id = 5 ORDER BY id;| id | name | cost | price | relative_margin |
|---|---|---|---|---|
| 18 | Bamboo toothbrush | 1.20 | 2.64 | 0.5455 |
| 19 | Natural stick deodorant 50 g | 3.30 | 7.26 | 0.5455 |
The relative margin comes out identical in both, and it's no coincidence: by fixing the price as cost * 2.2, the relative margin is always 1 − 1/2.2 = 0.5455, whatever the cost. It's the check that the formula does what it was meant to.
Notice the AND cost IS NOT NULL. In GreenStore every product has a cost, but the column is nullable (05-01): without that filter, a product with no cost would have received price = NULL, and since price is NOT NULL the whole statement would have failed. When the SET does arithmetic with a nullable column, the WHERE has to protect you — it's 04-03's three-valued logic applied to writing.
- Why the order of the assignments doesn't matter
This is the deepest difference between UPDATE and an imperative language, and it surprises everybody arriving from Java, Python or C.
In Python, price = cost * 2 followed by cost = cost * 1.1 would use the original cost on the first line and modify it on the second. In SQL the same thing happens, but for a different and stronger reason: it isn't that the assignments run in order, it's that they're all evaluated simultaneously over the previous row.
Product 5 had price = 1.95 and cost = 0.90:
| id | name | price | cost |
|---|---|---|---|
| 5 | Organic crushed tomato 400 g | 1.80 | 0.99 |
price = 0.90 × 2 = 1.80 (with the old cost, not with 0.99). cost = 0.90 × 1.1 = 0.99.
And now the definitive proof, which in an imperative language would need a temporary variable:
-- Swapping two columns: in SQL it works straight off
UPDATE products
SET price = cost,
cost = price
WHERE id = 3;| id | name | price | cost |
|---|---|---|---|
| 3 | Raw orange blossom honey 500 g | 5.40 | 9.75 |
They were at 9.75 and 5.40; now they're at 5.40 and 9.75. They've been swapped. In Python you'd have needed tmp = price or a multiple assignment; in SQL it's the default behaviour.
Three practical consequences:
- You can write the assignments in any order.
SET a = ..., b = ...andSET b = ..., a = ...are equivalent. - You can't chain calculations within the same
UPDATE.SET price = price * 1.1, vat = price * 0.21would compute the VAT on the old price, not on the newly raised one. If you need to chain, you need twoUPDATEs (inside the same transaction). - You can't assign the same column twice. PostgreSQL rejects it:
UPDATE ... FROM: updating from another table
UPDATE ... FROM: updating from another tableSo far, the new values came from the row itself. Often they come from another table. PostgreSQL solves this with a FROM clause in the UPDATE, which works just like a SELECT's FROM: it brings in additional tables and relates them to the one you're updating through the WHERE.
UPDATE target_table AS t
SET column = expression_using_o
FROM other_table AS o
WHERE t.key = o.key
AND other_conditions;Case 1: deducting the stock of an order
We come back to order 21 which we recorded in 05-02 (customer 6, three lines: 2 olive oils, 1 honey, 3 chamomile teas). The stock still has to be deducted:
| id | name | stock |
|---|---|---|
| 1 | Extra virgin olive oil 500 ml | 120 |
| 3 | Raw orange blossom honey 500 g | 80 |
| 14 | Organic chamomile tea 20 bags | 180 |
UPDATE products AS p
SET stock = p.stock - ol.quantity
FROM order_lines AS ol
WHERE ol.product_id = p.id
AND ol.order_id = 21;| id | name | stock |
|---|---|---|
| 1 | Extra virgin olive oil 500 ml | 118 |
| 3 | Raw orange blossom honey 500 g | 79 |
| 14 | Organic chamomile tea 20 bags | 177 |
120 − 2, 80 − 1, 180 − 3. Exactly right.
⚠️ The deadly trap of
UPDATE ... FROM. If a row ofproductsmatches several rows oforder_lines, PostgreSQL doesn't add them up: it picks one of the matches, non-deterministically, and applies that one. No error, no warning. If order 21 had two lines of the same product, thisUPDATEwould deduct only one of them.It's exactly module 3's row multiplication, but far more dangerous: in a
SELECTyou see it because extra rows come out; here it's invisible. The correct solution involves aggregating first (SUM(ol.quantity)grouped by product) and updating against that result, which requires a subquery in theFROM— module 7. In the meantime: before anUPDATE ... FROM, always check the matching is 1:1.
That check is done like this:
SELECT ol.product_id, COUNT(*) AS lines_
FROM order_lines AS ol
WHERE ol.order_id = 21
GROUP BY ol.product_id
HAVING COUNT(*) > 1;Zero rows: there are no repeated products and the matching is 1:1. Now we're safe.
Case 2: updating the prices of orders not yet shipped
We've just raised Drinks by 5 % (section 3). The orders already shipped or delivered must keep their historical price —that's the entire reason unit_price exists (01-05)—, but the ones that haven't gone out yet should bill the new tariff.
-- Step 1: see what's going to be touched
SELECT ol.id, ol.order_id, o.status, p.name,
ol.unit_price AS line_price,
p.price AS catalog_price
FROM order_lines AS ol
JOIN orders AS o ON ol.order_id = o.id
JOIN products AS p ON ol.product_id = p.id
WHERE o.status IN ('pending', 'paid')
AND ol.unit_price <> p.price
ORDER BY ol.id;| id | order_id | status | name | line_price | catalog_price |
|---|---|---|---|---|---|
| 45 | 19 | paid | Ginger kombucha 750 ml | 4.95 | 5.20 |
A single line. The rest of the lines of orders 18, 19 and 20 already match the catalogue.
-- Step 2: the UPDATE
UPDATE order_lines AS ol
SET unit_price = p.price
FROM products AS p,
orders AS o
WHERE ol.product_id = p.id
AND ol.order_id = o.id
AND o.status IN ('pending', 'paid')
AND ol.unit_price <> p.price
RETURNING ol.id, ol.order_id, ol.product_id, ol.unit_price;| id | order_id | product_id | unit_price |
|---|---|---|---|
| 45 | 19 | 16 | 5.20 |
Notice two things. First: in an UPDATE's FROM you can put several tables, separated by commas or joined with JOIN. Second: the table being updated (order_lines) isn't repeated in the FROM. If you put it there, PostgreSQL would treat it as a second, independent instance and the result would be a silent Cartesian product.
Equivalents by engine
This is one of the module's biggest divergences:
| Engine | Syntax |
|---|---|
| PostgreSQL | UPDATE t SET c = o.c FROM other o WHERE t.k = o.k |
| MySQL / MariaDB | UPDATE t JOIN other o ON t.k = o.k SET t.c = o.c — the JOIN goes before the SET |
| SQL Server | UPDATE t SET c = o.c FROM target t JOIN other o ON t.k = o.k — the target table is repeated in the FROM |
| SQLite | UPDATE t SET c = (...) FROM other o WHERE t.k = o.k since version 3.33; before that, a correlated subquery |
| Oracle | It has no UPDATE ... FROM. You use MERGE (05-05) or a correlated subquery |
And the SQL standard, in fact, defines none of the four: the portable form is a correlated subquery in the SET, which you'll see in module 7.
RETURNING: seeing what you've changed
RETURNING: seeing what you've changedJust as in INSERT, RETURNING returns the affected rows. In UPDATE it's even more useful, because it shows you the result:
UPDATE orders
SET status = 'delivered'
WHERE status = 'shipped'
AND order_date < DATE '2026-02-01'
RETURNING id, customer_id, order_date, status, payment_method;| id | customer_id | order_date | status | payment_method |
|---|---|---|---|---|
| 16 | 4 | 2025-12-19 | delivered | card |
| 17 | 7 | 2026-01-13 | delivered | paypal |
Two orders that had been "shipped" for months move to "delivered". And you see it without needing a later SELECT.
What RETURNING can't do: return the previous value. It only sees the already-updated row. If you need to keep the old value, you have three options: copy the table beforehand (section 4), an INSERT ... SELECT into an audit table before the UPDATE (05-02), or an audit trigger (module 10).
Dialect note: SQL Server can do it: its
OUTPUT DELETED.column, INSERTED.columnclause returns the before and the after in the same statement. It's one of the few things where its syntax beats PostgreSQL's.
- An
UPDATE that violates a constraint
UPDATE that violates a constraintAn UPDATE is subject to exactly the same constraints as an INSERT, and it gives the same errors. The difference is that now the row already existed and was correct.
CHECK:
ERROR: new row for relation "products" violates check constraint "products_stock_check" DETAIL: Failing row contains (13, Soy wax candles (pack of 2), 3, 5, 13.75, 6.90, -1, t, 2025-03-01).
Product 13 had stock 0 and CHECK (stock >= 0) stops it going down to −1. That CHECK, which in 05-01 looked like bureaucracy, has just prevented a negative stock.
A domain CHECK:
ERROR: new row for relation "orders" violates check constraint "orders_status_check" DETAIL: Failing row contains (6, 5, 4, 2025-05-23, returned, card, 4.95).
UNIQUE:
ERROR: duplicate key value violates unique constraint "customers_email_key" DETAIL: Key (email)=(lucia.martinez@example.com) already exists.
FOREIGN KEY:
ERROR: insert or update on table "orders" violates foreign key constraint "orders_customer_id_fkey" DETAIL: Key (customer_id)=(999) is not present in table "customers".
NOT NULL:
ERROR: null value in column "customer_id" of relation "orders" violates not-null constraint DETAIL: Failing row contains (1, null, null, 2025-03-04, delivered, card, 4.95).
In all five cases, the original row is left untouched: an UPDATE that fails modifies nothing. And as with INSERT, if the UPDATE affected ten rows and one of them violates a constraint, none of them is updated.
- Updating to
NULL, and the effect of ON UPDATE CASCADE
NULL, and the effect of ON UPDATE CASCADESetting a column to NULL
You do it with = NULL, not with IS NULL (which is a comparison operator, 04-03):
UPDATE orders
SET employee_id = NULL
WHERE id = 2
RETURNING id, customer_id, employee_id, order_date, status;| id | customer_id | employee_id | order_date | status |
|---|---|---|---|---|
| 2 | 2 | (null) | 2025-03-12 | delivered |
Order 2 stops having a sales rep assigned: it starts behaving like the ten web orders. There would now be 11 orders with employee_id IS NULL.
You can only do it if the column allows nulls. And a semantic warning carried over from 04-03: NULL means "unknown", not "none". Setting employee_id to NULL to mean "the web handled it" is correct because the schema defines that null that way. Setting salary = NULL to mean "they're paid 0" would be a design error: 0 and "I don't know" are different things.
ON UPDATE CASCADE
When a parent's primary key value changes, ON UPDATE decides what happens to the children. GreenStore doesn't use it because its PKs are surrogate and never change (01-05). With an example table you can see it clearly:
-- A one-off example: tables with a natural key, outside GreenStore's schema
CREATE TABLE countries (
code VARCHAR(3) PRIMARY KEY,
name VARCHAR(60) NOT NULL
);
CREATE TABLE shipping_rates (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
country_code VARCHAR(3) NOT NULL REFERENCES countries(code)
ON UPDATE CASCADE ON DELETE RESTRICT,
max_weight_kg NUMERIC(5,2) NOT NULL,
amount NUMERIC(10,2) NOT NULL
);
INSERT INTO countries (code, name) VALUES ('ESP', 'Spain'), ('PRT', 'Portugal'), ('FRA', 'France');
INSERT INTO shipping_rates (country_code, max_weight_kg, amount) VALUES
('ESP', 2.00, 4.95), ('ESP', 10.00, 6.50), ('PRT', 2.00, 9.90), ('FRA', 2.00, 12.50);Now the company decides to migrate to two-letter ISO codes:
| id | country_code | max_weight_kg | amount |
|---|---|---|---|
| 1 | ES | 2.00 | 4.95 |
| 2 | ES | 10.00 | 6.50 |
| 3 | PRT | 2.00 | 9.90 |
| 4 | FRA | 2.00 | 12.50 |
The two child rows have updated themselves. Without ON UPDATE CASCADE, the UPDATE would have failed:
ERROR: update or delete on table "countries" violates foreign key constraint "shipping_rates_country_code_fkey" on table "shipping_rates" DETAIL: Key (code)=(ESP) is still referenced from table "shipping_rates".
And here's 01-05's argument in its sharpest form: with surrogate keys this problem doesn't exist. ON UPDATE CASCADE is the answer to a question only somebody who chose a mutable natural key ever asks.
- Idempotence: why it matters when a script is rerun
An operation is idempotent if running it twice gives the same result as running it once. It's a crucial property in any automated process, because sooner or later a script runs twice: it fails halfway and is relaunched, it gets deployed twice, somebody isn't sure whether it ever ran.
Compare:
-- ❌ NOT idempotent
UPDATE products SET price = price * 1.05 WHERE category_id = 4;
-- ✅ Idempotent
UPDATE products SET price = 3.41 WHERE id = 14;With the first, starting from the original price of €3.25:
| Run | Resulting price |
|---|---|
| 1st | 3.41 |
| 2nd | 3.58 |
| 3rd | 3.76 |
The price runs away without anybody noticing. With the second, the result is €3.41 all three times.
The rule that follows:
Form of the SET |
Idempotent? | Why |
|---|---|---|
SET col = fixed_value |
Yes | It sets an absolute state |
SET col = another_column |
Yes | The source doesn't change |
SET col = col * k, col + k, col - k |
No | Every run starts from the previous result |
SET col = col + 1 (counters) |
No | It's exactly the opposite of what you want |
And the three ways of living with a non-idempotent UPDATE when the relative calculation is what you need:
- Make it idempotent through the
WHERE. If you can identify the "not yet processed" rows, the second pass finds nothing:
The first run returns UPDATE 2; the second, UPDATE 0, because there's no longer any order in shipped status with that date. A well-chosen WHERE turns a relative operation into an idempotent one. It's the most important pattern in this section.
-
Record that it's already been done, in a control table or with a flag column, and check it beforehand.
-
Wrap it in a versioned migration, which by definition is applied only once. That's 05-06's topic.
Golden tip: when you write a script that's going to run unsupervised, always ask yourself "what happens if this runs twice?". If the answer is "a silent disaster", rewrite it.
- Four GreenStore business cases
All of them on the freshly reloaded database, with the complete protocol.
13.1. Raising the price of a category
Already solved in section 3, with its preliminary SELECT, its count of 4 rows, its preview and its verification. It's the canonical case.
13.2. Marking as delivered the orders shipped more than a month ago
BEGIN;
SELECT COUNT(*) AS affected
FROM orders
WHERE status = 'shipped'
AND order_date < DATE '2026-02-01';| affected |
|---|
| 2 |
UPDATE orders
SET status = 'delivered'
WHERE status = 'shipped'
AND order_date < DATE '2026-02-01'
RETURNING id, customer_id, order_date, status;| id | customer_id | order_date | status |
|---|---|---|---|
| 16 | 4 | 2025-12-19 | delivered |
| 17 | 7 | 2026-01-13 | delivered |
| status | orders |
|---|---|
| delivered | 16 |
| paid | 2 |
| cancelled | 1 |
| pending | 1 |
From 14 delivered to 16, and the shipped status disappears. It matches the 2 rows announced. Note the use of the half-open date range (< DATE '2026-02-01') following the course's convention.
13.3. Correcting a mistyped email
Customer 8 lets us know his address is wrong:
| id | name | last_name | country | |
|---|---|---|---|---|
| 8 | Tiago | Almeida Nunes | tiago.almeida@example.pt | Portugal |
UPDATE customers
SET email = 'tiago.a.nunes@example.pt'
WHERE id = 8
RETURNING id, name, last_name, email;| id | name | last_name | |
|---|---|---|---|
| 8 | Tiago | Almeida Nunes | tiago.a.nunes@example.pt |
A single-row correction, the most frequent operation of all. Two observations:
- We filter by
id, not by email. Filtering by the value you're about to change works, but if there were a typo in theWHEREyou'd touch no rows at all (UPDATE 0) and you might believe it was already fine. The PK is always the safest filter. - The
UNIQUEis still watching. If the new address already belonged to another customer, theUPDATEwould fail withduplicate key value violates unique constraint "customers_email_key".
13.4. Discontinuing a product: soft delete
Product 13 (Soy wax candles) has never been sold and has been at zero for months:
UPDATE products
SET active = FALSE,
stock = 0
WHERE id = 13
RETURNING id, name, price, stock, active;| id | name | price | stock | active |
|---|---|---|---|---|
| 13 | Soy wax candles (pack of 2) | 13.75 | 0 | false |
This is a soft delete, and it's the correct alternative to DELETE FROM products WHERE id = 13. The product disappears from the sales catalogue but keeps its row, its history and its referential integrity. The active column exists for exactly this, and the topic gets a full treatment in the next lesson.
What to remember today: from now on, every catalogue query needs WHERE active:
SELECT COUNT(*) FILTER (WHERE active) AS on_sale,
COUNT(*) FILTER (WHERE NOT active) AS discontinued,
COUNT(*) AS total
FROM products;| on_sale | discontinued | total |
|---|---|---|
| 18 | 2 | 20 |
Eighteen on sale (number 13 has just joined number 20, which was already inactive). That FILTER is 04-04's, now useful for auditing the result of a write.
Common Mistakes and Tips
- Forgetting the
WHERE. It modifies every row with no warning. It's the module's emblematic mistake and the reason for the five-step protocol. - Rewriting the
WHEREfrom memory when moving from theSELECTto theUPDATE. Copy it literally. Half of all incidents come from a condition that "was almost the same". - Not looking at the
UPDATE N. It's the only warning you're going to get. Always compare it with the prior count. - Working without
BEGIN. In autocommit there's no way back. ABEGINcosts five letters. - Leaving a transaction open. It locks rows for every other session. Commit or roll back before you get up.
- Expecting
RETURNINGto give the previous value. It only sees the new one. For the before, copy the table or audit it. - Chaining calculations in a single
SET.SET a = a * 1.1, b = a * 0.21uses the oldato computeb. If you need to chain, twoUPDATEs. - Using
UPDATE ... FROMwith a 1:N matching. It doesn't add up: it picks an arbitrary match and gives no warning. Check with aGROUP BY ... HAVING COUNT(*) > 1beforehand. - Repeating the target table in PostgreSQL's
FROM. It produces a silent Cartesian product. In SQL Server it's the other way round: you have to repeat it. - Arithmetic over a nullable column without protecting it in the
WHERE.price = cost * 2with a nullcostgivesNULLand blows up against theNOT NULL. - Writing non-idempotent
UPDATEs in automated scripts.price = price * 1.05run twice raises by 10.25 %. Set absolute values or narrow it down with theWHERE. - Updating a column that represents history.
unit_priceis the price at the moment of the sale. Touching it on already delivered orders falsifies the accounts. - Tip: filter by the primary key whenever you can. It's the safest
WHEREthere is. - Tip: preview the
SETas a calculated column. Seeing the new values before writing them catches rounding, negatives and unexpected nulls. - Tip: for big changes,
CREATE TABLE copy AS SELECT * FROM tablefirst. Thirty seconds that are worth a whole night.
Exercises
Work on the freshly reloaded database and use BEGIN … ROLLBACK so you don't drag changes from one exercise into the next.
Exercise 1
Management approves a price review for Natural cosmetics (category 2) with these rules:
- Raise the price by 8 %, rounding to two decimals.
- Raise the cost by 3 %, rounding to two decimals.
- It must only affect the active products.
Apply the complete protocol: preliminary query, count, preview with the relative margin before and after, UPDATE inside a transaction, verification and COMMIT. Then answer: has the relative margin improved or worsened? Why?
Exercise 2
A colleague hands you this UPDATE with the comment "I want to bring the line prices of every order that hasn't shipped yet up to date, and I'm getting a strange number":
-- ⚠️ INCORRECT
UPDATE order_lines
SET unit_price = products.price
FROM products, orders, order_lines
WHERE order_lines.product_id = products.id
AND order_lines.order_id = orders.id
AND orders.status = 'pending';- Find the error and explain what this statement really does.
- Rewrite it correctly, with aliases, and say how many rows it should touch on the freshly reloaded database.
- Explain why this operation would be catastrophic if the
WHEREincludedorders.status = 'delivered'.
Exercise 3
The warehouse has taken a delivery and stock needs replenishing. The data comes in this auxiliary table:
CREATE TEMP TABLE warehouse_receipt (
product_id INTEGER NOT NULL,
units INTEGER NOT NULL CHECK (units > 0)
);
INSERT INTO warehouse_receipt (product_id, units) VALUES
(1, 40), (5, 100), (13, 25), (16, 30);- Write the
UPDATE ... FROMthat adds the units received to each product's stock. - Check beforehand that the matching is 1:1.
- Show the state before and after, and explain what happens to product 13.
- Is this
UPDATEidempotent? If it isn't, what would happen if the warehouse ran the script twice by mistake, and how would you avoid it?
Solutions
Solution 1
-- Steps 1 and 2: which rows and how many
SELECT id, name, price, cost, active
FROM products
WHERE category_id = 2
AND active
ORDER BY id;| id | name | price | cost | active |
|---|---|---|---|---|
| 6 | Aloe vera face cream 50 ml | 18.90 | 9.50 | true |
| 7 | Rosemary solid shampoo 80 g | 8.40 | 3.60 | true |
| 8 | Almond body oil 200 ml | 14.25 | 7.10 | true |
| 9 | Calendula lip balm 15 ml | 4.60 | 1.80 | true |
4 rows. All four Natural cosmetics products are active.
-- Step 3: the preview
SELECT id,
name,
price,
cost,
ROUND((price - cost) / price, 4) AS margin_before,
ROUND(price * 1.08, 2) AS new_price,
ROUND(cost * 1.03, 2) AS new_cost,
ROUND((ROUND(price * 1.08, 2) - ROUND(cost * 1.03, 2))
/ ROUND(price * 1.08, 2), 4) AS margin_after
FROM products
WHERE category_id = 2 AND active
ORDER BY id;| id | name | price | cost | margin_before | new_price | new_cost | margin_after |
|---|---|---|---|---|---|---|---|
| 6 | Aloe vera face cream 50 ml | 18.90 | 9.50 | 0.4974 | 20.41 | 9.79 | 0.5203 |
| 7 | Rosemary solid shampoo 80 g | 8.40 | 3.60 | 0.5714 | 9.07 | 3.71 | 0.5910 |
| 8 | Almond body oil 200 ml | 14.25 | 7.10 | 0.5018 | 15.39 | 7.31 | 0.5250 |
| 9 | Calendula lip balm 15 ml | 4.60 | 1.80 | 0.6087 | 4.97 | 1.85 | 0.6278 |
-- Step 4: the UPDATE, inside a transaction
BEGIN;
UPDATE products
SET price = ROUND(price * 1.08, 2),
cost = ROUND(cost * 1.03, 2)
WHERE category_id = 2
AND active
RETURNING id, name, price, cost;| id | name | price | cost |
|---|---|---|---|
| 6 | Aloe vera face cream 50 ml | 20.41 | 9.79 |
| 7 | Rosemary solid shampoo 80 g | 9.07 | 3.71 |
| 8 | Almond body oil 200 ml | 15.39 | 7.31 |
| 9 | Calendula lip balm 15 ml | 4.97 | 1.85 |
-- Step 5: verify and commit
SELECT ROUND(AVG((price - cost) / price), 4) AS avg_margin
FROM products WHERE category_id = 2 AND active;| avg_margin |
|---|
| 0.5660 |
The relative margin improves on all four products (from an average of 0.5448 to 0.5660), and the reason is arithmetic: the price goes up 8 % and the cost only 3 %, so the difference grows faster than the price. It's the check that the price review does what management intended.
Two decisions in the brief you had to respect: the explicit ROUND (even though NUMERIC(10,2) would round the same way, writing it makes the intent visible) and the AND active filter, which discards no row here but shields the statement against a future discontinued product.
Solution 2
1. The error. order_lines appears twice: once as the UPDATE's target table and once inside the FROM. PostgreSQL treats them as two independent instances, so the one in the FROM isn't correlated with the one being updated. The result is a silent Cartesian product: the condition order_lines.product_id = products.id is resolved against the FROM's instance, and the UPDATE ends up touching every line in the table, giving them an arbitrary price. Hence "the strange number".
PostgreSQL even warns about something similar in the documentation, but it gives no error: the statement is legal.
2. The correct version:
-- ✅ CORRECT
UPDATE order_lines AS ol
SET unit_price = p.price
FROM products AS p,
orders AS o
WHERE ol.product_id = p.id
AND ol.order_id = o.id
AND o.status = 'pending'
AND ol.unit_price <> p.price
RETURNING ol.id, ol.order_id, ol.product_id, ol.unit_price;Zero rows on the freshly reloaded database. The only pending order is number 20, and its two lines (products 2 and 18, at €3.90 and €3.50) already match the catalogue. That UPDATE 0 is valuable information, not a failure: it confirms nothing is out of date.
Three changes with respect to the original: aliases have been added, order_lines has been removed from the FROM, and AND ol.unit_price <> p.price has been added so as not to touch rows that are already fine (which, incidentally, makes the statement idempotent).
3. Why it would be catastrophic with 'delivered'. unit_price holds the price at the moment of the sale: it's 01-05's deliberate denormalisation and the basis of all the historical accounting. Rewriting it with the current price would mean that invoices issued a year ago change their amounts retroactively.
The concrete damage to GreenStore: lines 1 and 4 carry historical prices (€11.95 and €17.50) against the current ones (€12.50 and €18.90). This UPDATE would rewrite them and the total revenue would go from €727.95 to a different figure, invalidating every quantity published in modules 3 and 4. And there'd be no way of getting the old values back: they'd no longer be anywhere.
It's the perfect example of an UPDATE that runs with no error, with no warning, and destroys unrecoverable information.
Solution 3
-- 2) Check the 1:1 matching BEFOREHAND
SELECT product_id, COUNT(*) AS times
FROM warehouse_receipt
GROUP BY product_id
HAVING COUNT(*) > 1;No repeated product: each row of products will match a single row of warehouse_receipt.
-- 3) The state before
SELECT p.id, p.name, p.stock, r.units AS received
FROM products AS p
JOIN warehouse_receipt AS r ON r.product_id = p.id
ORDER BY p.id;| id | name | stock | received |
|---|---|---|---|
| 1 | Extra virgin olive oil 500 ml | 120 | 40 |
| 5 | Organic crushed tomato 400 g | 300 | 100 |
| 13 | Soy wax candles (pack of 2) | 0 | 25 |
| 16 | Ginger kombucha 750 ml | 60 | 30 |
-- 1) The UPDATE
BEGIN;
UPDATE products AS p
SET stock = p.stock + r.units
FROM warehouse_receipt AS r
WHERE r.product_id = p.id
RETURNING p.id, p.name, p.stock;| id | name | stock |
|---|---|---|
| 1 | Extra virgin olive oil 500 ml | 160 |
| 5 | Organic crushed tomato 400 g | 400 |
| 13 | Soy wax candles (pack of 2) | 25 |
| 16 | Ginger kombucha 750 ml | 90 |
What happens to product 13. It goes from 0 to 25 units: it stops being out of stock. It's a change with consequences beyond the table, because product 13 is one of the course data set's "deliberate gaps" (01-06): it had stock 0 and had never been sold. If you leave this change committed, some examples from earlier modules will stop matching. Reload the script before continuing.
4. Idempotence. It isn't idempotent. SET stock = stock + units is a relative operation: running it twice would add the units twice, and the warehouse would have 200 units of olive oil in the system and 160 on the shelf. An inventory discrepancy nobody would spot until the annual stocktake.
| Run | Product 1's stock |
|---|---|
| 0 (initial) | 120 |
| 1st | 160 |
| 2nd | 200 ← wrong |
The three ways to avoid it, from least to most robust:
- Flag the receipts already processed. Add a
processed BOOLEAN NOT NULL DEFAULT FALSEcolumn towarehouse_receipt, filter byWHERE NOT r.processedin theUPDATEand set it toTRUEin the same transaction. The second run finds nothing. - Record every movement in a
stock_movementstable with its own unique key, and compute the stock as the sum of the movements instead of storing it as a mutable value. It's the ledger approach, the most robust one and the one serious inventory systems use. - Turn it into a versioned migration, which by construction is applied only once (05-06).
And the property that makes option 1 work: whenever you can identify the "not yet processed" rows in the WHERE, a relative operation becomes idempotent. It's the same pattern as case 13.2, where WHERE status = 'shipped' guarantees the second pass returns UPDATE 0.
Conclusion
UPDATE is the course's first statement that can destroy information, and you now know how to handle it:
- The syntax
UPDATE table SET col = value WHERE ..., where theWHEREdoes the same job as in aSELECTbut with different consequences.UPDATE Ncounts the rows that matched the condition, not the ones whose value changed. - With no
WHERE, every row is modified: 20 products instead of 4, with no error, no warning and no way back. - The five-step protocol:
SELECTwith the finalWHERE→ count rows → preview the new values as a calculated column → turn it into anUPDATEcopying theWHEREliterally → verify. And comparing the prior count with theUPDATE Nas an alarm. BEGIN… verify …COMMIT/ROLLBACKas a real safety net, with its three practical warnings: it locks rows, an error aborts the whole block, and sequences aren't undone. The complete model —ACID, isolation, locks— is module 9.- Several columns in a single
SET, which avoids the visible intermediate state of two consecutiveUPDATEs. - Calculations over the previous value (
SET price = price * 1.05) and the golden rule: all the assignments are evaluated simultaneously over the old row. That's why the order doesn't matter, whySET a = b, b = aswaps two columns with no temporary variable, and why calculations can't be chained. UPDATE ... FROMfor updating from another table, with its deadly trap: a 1:N matching doesn't add up, it picks an arbitrary match and gives no warning. And the four incompatible syntaxes of PostgreSQL, MySQL, SQL Server and Oracle.RETURNINGto see the result, which can't return the previous value (except through SQL Server'sOUTPUT).- The five constraint errors, identical to
INSERT's, with the original row untouched when they fail. - Updating to
NULLand its semantics, andON UPDATE CASCADE, which is only needed with mutable natural keys. - Idempotence:
SET col = fixed_valueyes,SET col = col * kno; and the pattern that solves it, narrowing theWHEREto the "not yet processed" rows so the second run returnsUPDATE 0. - And the real cases: a category price rise, closing old orders, correcting an email and discontinuing with
active = FALSE, the soft delete.
That last case is the door to the next lesson. In The DELETE Statement you'll learn to remove rows for real —with the same protocol, reinforced—, you'll see the difference between DELETE and TRUNCATE, you'll check with row counts what happens when you delete a referenced row depending on whether its ON DELETE is RESTRICT, CASCADE or SET NULL, and you'll arrive at the underlying question: if active = FALSE keeps the history and DELETE destroys it, why ever delete anything? The answer has to do with auditing, with performance and with the right to erasure of personal data.
SQL Course
Module 1: Introduction to SQL
- What is SQL?
- Setting up your SQL environment
- Basic SQL syntax
- Understanding databases and tables
- The relational model: primary and foreign keys
- The course database: GreenStore
Module 2: Basic SQL queries
- The SELECT statement
- Aliases, expressions and calculated columns
- Filtering data with WHERE
- DISTINCT and removing duplicates
- Sorting data with ORDER BY
- Limiting results with LIMIT
Module 3: Working with multiple tables
- JOIN operations
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN and CROSS JOIN
- Set operations: UNION, INTERSECT and EXCEPT
Module 4: Advanced data filtering
- Using LIKE for pattern matching
- The IN and BETWEEN operators
- NULL values and IS NULL
- Aggregate functions: COUNT, SUM, AVG, MIN and MAX
- Aggregating data with GROUP BY
- The HAVING clause
Module 5: Data manipulation
- Creating tables and constraints with CREATE TABLE
- The INSERT statement
- The UPDATE statement
- The DELETE statement
- The UPSERT (MERGE) statement
- Changing the schema: ALTER TABLE and safe migrations
Module 6: Advanced SQL functions
- String functions
- Numeric functions
- Date and time functions
- Type conversion and handling NULL: CAST and COALESCE
- Conditional expressions
Module 7: Subqueries and nested queries
- Introduction to subqueries
- Correlated subqueries
- EXISTS and NOT EXISTS
- Using subqueries in SELECT, FROM and WHERE
- Subquery or JOIN: which one to choose
Module 8: Indexes and performance tuning
- Understanding indexes
- Creating and managing indexes
- Index types and when not to index
- Query optimization techniques
- Analyzing query performance
Module 9: Transactions and concurrency
- Introduction to transactions
- ACID properties
- Transaction control statements
- Isolation levels and concurrency anomalies
- Handling concurrency: locks and deadlocks
Module 10: Advanced topics
- Views
- Common table expressions (CTEs)
- Window functions
- Stored procedures
- Triggers
- JSON and semi-structured data
Module 11: SQL in practice
- Real-world use cases
- Best practices
- Security: SQL injection, permissions and roles
- SQL for data analysis
- SQL in web development
