Home Database SQL UPDATE from SELECT: 4 Bulletproof Patterns That Work
Intermediate 3 min · September 07, 2026
SQL UPDATE from SELECT Statement

SQL UPDATE from SELECT: 4 Bulletproof Patterns That Work

UPDATE without a join guard rewrote 4.2M rows instead of 18k.

N
Naren Founder & Principal Engineer

20+ years shipping high-throughput database systems. Notes here come from systems that actually shipped.

Follow
Production
production tested
September 22, 2026
last updated
1,799
articles · all by Naren
Before you start⏱ 13 min
  • Comfortable with SELECT, JOIN, and WHERE filtering
  • Basic transactions: BEGIN, COMMIT, ROLLBACK
  • Access to a scratch database for safe practice
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • UPDATE from SELECT modifies one table using values computed from other tables — via FROM/JOIN clauses or correlated subqueries
  • Four portable patterns: Postgres UPDATE..FROM, SQL Server UPDATE..FROM+JOIN, MySQL UPDATE..JOIN, and standard correlated subquery that runs everywhere
  • Performance insight: an indexed join-key update touched 18k rows in 1.2s; the missing-join version rewrote 4.2M rows and held locks for 11 minutes
  • Production insight: non-deterministic multi-match (two source rows per target) silently picks one row — dedupe the source or use MERGE with a guard
  • Rule: always write the SELECT first, verify row counts, then convert to UPDATE in a transaction with RETURNING or OUTPUT
✦ Definition~90s read
What is SQL UPDATE from SELECT Statement?

UPDATE from SELECT is any UPDATE statement whose SET values or row filter derive from a query over other tables. Two mechanisms exist: join-style (listing source tables in FROM/JOIN and matching on keys) and subquery-style (scalar or EXISTS subqueries in SET/WHERE referencing the target row).

Imagine a gradebook where the scores live in one spreadsheet and the curve lives in another.

The legendary Stack Overflow thread on this exact question collected per-dialect answers because no single syntax runs everywhere: PostgreSQL and SQL Server support UPDATE..FROM (with different alias rules), MySQL uses UPDATE..JOIN, SQLite follows Postgres loosely, and Oracle leans on MERGE or correlated subqueries. The portable core — UPDATE t SET col = (SELECT ...) WHERE EXISTS (...) — runs on all of them with care around NULLs and multi-row matches.

Plain-English First

Imagine a gradebook where the scores live in one spreadsheet and the curve lives in another. Updating grades from the curve means looking up each student's row in the second sheet and writing the adjusted score into the first. SQL's UPDATE-from-SELECT does exactly that lookup-and-write in one step. The danger is a sloppy lookup that matches every student to the same curve row — suddenly the whole class has one person's grade. Write the lookup as a SELECT first, check the matches, then flip it into an UPDATE.

You need to backfill a column from another table — copy the new prices over, sync the statuses, fix the emails from the staging import. A plain UPDATE can't see the other table, so you reach for UPDATE-from-SELECT. Then the dialect differences hit.

Postgres wants UPDATE..FROM, MySQL wants UPDATE..JOIN, SQL Server wants its own FROM flavor, and the 'portable' correlated subquery has traps of its own. Pick the wrong shape and you update every row or fail with a cryptic alias error.

There's a safe path. You'll learn four patterns — one per major dialect plus the portable fallback — and the SELECT-first workflow that makes runaway updates structurally impossible.

The SELECT-First Workflow That Prevents Disasters

Every safe UPDATE-from-SELECT starts life as a SELECT. Write SELECT t.id, t.price, s.price FROM products t JOIN price_feed s ON s.sku = t.sku and eyeball the rows: right count, right matches, no duplicates per key.

Check two counts: SELECT count() of the join (expect 18,204, not 4.2M) and SELECT key, count() GROUP BY key HAVING count(*) > 1 to catch multi-matches. Both take seconds and catch the entire incident class above.

Only then convert: swap the SELECT list for UPDATE products SET price = s.price keeping FROM/WHERE identical. Run in a transaction, verify with RETURNING, commit. The workflow is slower by two minutes and safer by three-hour rollbacks.

📊 Production Insight
The 4.2M-row incident would have shown 4.2M in the SELECT count step. Two minutes of counting replaces three hours of snapshot rollback.
🎯 Key Takeaway
SELECT the join, count it, dedupe-check it — then convert to UPDATE in a transaction.

Pattern 1: PostgreSQL UPDATE..FROM Done Right

Postgres syntax: UPDATE products SET price = s.price FROM price_feed s WHERE s.sku = products.sku AND s.region = 'EU'. The FROM lists sources; the WHERE carries both the join predicate and filters. Forget the predicate and every row matches everything.

Alias discipline matters: UPDATE products AS p SET price = s.price FROM price_feed s WHERE s.sku = p.sku. Never repeat the bare target name in FROM (that's a self-join requiring its own alias) — the classic 'table specified more than once' error.

Add RETURNING id, price for an instant audit trail of touched rows. For multi-match safety, pre-aggregate the source: FROM (SELECT sku, max(price) FROM price_feed GROUP BY sku) s guarantees one row per key.

pg_update_from.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- 1. SELECT first: verify matches + counts
SELECT p.id, p.price AS old, s.price AS new
FROM products AS p
JOIN price_feed AS s ON s.sku = p.sku
WHERE s.region = 'EU';

-- 2. Convert to UPDATE (same FROM/WHERE), audit with RETURNING
BEGIN;
UPDATE products AS p
SET price = s.price, updated_at = now()
FROM price_feed AS s
WHERE s.sku = p.sku
  AND s.region = 'EU'
RETURNING p.id, p.price;
-- COMMIT;  -- only after checking the RETURNING output
ROLLBACK; -- practice safely: inspect, then commit for real
📊 Production Insight
RETURNING output is what the team pastes into the deploy ticket — 18,204 rows with before/after prices, reviewed before COMMIT.
🎯 Key Takeaway
FROM sources, WHERE joins + filters, alias the target, RETURNING audits. Dedupe multi-match sources.

Pattern 2: SQL Server UPDATE..FROM with JOINs

SQL Server hangs the UPDATE off an alias and joins in FROM: UPDATE p SET p.price = s.price FROM products p INNER JOIN price_feed s ON s.sku = p.sku WHERE s.region = 'EU'. The alias after UPDATE is load-bearing — UPDATE products with a later p alias errors.

Prefer explicit INNER JOIN over comma joins for readability; both run, but the JOIN form keeps predicates attached to their tables. For top-N guarded updates, the proprietary UPDATE TOP (1000) exists — but batch with WHILE loops and keyset filters instead for restartable migrations.

SQL Server 2008+ also offers MERGE with WHEN MATCHED THEN UPDATE, which adds niet-determinism guards (it errors on multiple source matches instead of silently picking). For sync jobs where duplicates are possible, MERGE's loud failure beats UPDATE's silent pick.

mssql_update_from.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- SQL Server: alias after UPDATE, JOIN in FROM
BEGIN TRAN;
UPDATE p
SET p.price = s.price,
    p.updated_at = SYSDATETIME()
FROM products AS p
INNER JOIN price_feed AS s ON s.sku = p.sku
WHERE s.region = 'EU';
-- SELECT @@ROWCOUNT;  -- expect 18204, not 4200000
-- COMMIT;  -- after verifying rowcount
-- ROLLBACK;

-- Safer sync alternative: MERGE errors on duplicate matches
-- MERGE INTO products AS p USING price_feed AS s ON s.sku = p.sku
-- WHEN MATCHED AND s.region = 'EU' THEN UPDATE SET p.price = s.price;
📊 Production Insight
A SQL Server sync using MERGE caught a duplicate-feed day by erroring instead of silently applying one of two prices — the loud failure saved a pricing dispute.
🎯 Key Takeaway
UPDATE alias SET ... FROM alias JOIN source. Check @@ROWCOUNT; consider MERGE for duplicate detection.

Pattern 3: MySQL UPDATE..JOIN (and the 1093 Trap)

MySQL joins before SET: UPDATE products p INNER JOIN price_feed s ON s.sku = p.sku SET p.price = s.price WHERE s.region = 'EU'. Table list first, assignments after — the reverse order of other dialects, and the top syntax-memorization failure.

The infamous ERROR 1093 blocks subqueries selecting from the target table: UPDATE items SET ... WHERE id IN (SELECT id FROM items ...) fails. Fix it MySQL-style with the JOIN form, or wrap the inner query as a derived table (SELECT ... ) AS d to force materialization.

MySQL multi-table UPDATE can touch several tables at once, but don't — one table per statement keeps rowcounts interpretable and rollbacks clean. Batch large updates with LIMIT + keyset loops to avoid 11-minute lock holds.

mysql_update_join.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- MySQL: JOIN first, SET after
START TRANSACTION;
UPDATE products AS p
INNER JOIN price_feed AS s ON s.sku = p.sku
SET p.price = s.price,
    p.updated_at = NOW()
WHERE s.region = 'EU';
-- SELECT ROW_COUNT();  -- sanity-check before commit
-- COMMIT;
-- ROLLBACK;

-- 1093 workaround: derived table forces materialization
-- UPDATE products AS p
-- INNER JOIN (SELECT sku, MAX(price) AS price FROM price_feed GROUP BY sku) AS s
--   ON s.sku = p.sku
-- SET p.price = s.price;
⚠ MySQL order is reversed
UPDATE..JOIN..SET trips up everyone coming from Postgres. The join comes before the assignments — write one MySQL-shaped example by hand before trusting muscle memory.
📊 Production Insight
A 1093 error at 1 AM became a 5-minute JOIN rewrite once the team kept this exact derived-table template in the runbook.
🎯 Key Takeaway
JOIN before SET in MySQL; derived tables dodge 1093; one table per UPDATE.

Pattern 4: Portable Correlated Subquery (Runs Everywhere)

When one migration must run on Postgres, MySQL, and SQLite, use the standard form: UPDATE products SET price = (SELECT s.price FROM price_feed s WHERE s.sku = products.sku) WHERE EXISTS (SELECT 1 FROM price_feed s WHERE s.sku = products.sku AND s.region = 'EU'). Ugly, universal.

Two rules keep it correct: the scalar subquery must return at most one row per target (aggregate or LIMIT 1 with ORDER BY if the source can duplicate), and the EXISTS clause prevents NULL-writes to unmatched rows. Drop EXISTS and every non-matching product gets price = NULL — a second outage flavor.

It's slower than dialect JOINs (correlated execution per row without good indexes), so index the source key (price_feed.sku) first. Portability costs a nested loop; the index keeps it affordable.

📊 Production Insight
The portable form runs the team's seed migrations across Postgres (prod) and SQLite (CI) from one file — dialect JOINs would need two.
🎯 Key Takeaway
Scalar SET subquery + matching EXISTS guard. Aggregate to one row per key; index the source key.

Locking, Batching, and Running at Scale

An 18k-row update takes a second; a 4M-row one holds locks for 11 minutes and blocks checkout. Batch by keyset: UPDATE ... WHERE id > $last AND id <= $last + 5000 in a loop, committing per batch. Restartable, lock-friendly, and progress-visible.

Order matters for deadlocks: process keys ascending everywhere so concurrent jobs lock in the same sequence. Avoid updating the indexed join key itself mid-migration — it churns the very index the scan uses.

Schedule right: run backfills in low-traffic windows, announce lock scope in the deploy ticket, and keep the snapshot/rollback command pasted beside the migration. The incident's 3-hour rollback started with 20 minutes just finding the snapshot procedure.

📊 Production Insight
Keyset-batched reruns of the fixed migration never held a lock over 800ms — checkout stayed green throughout the backfill.
🎯 Key Takeaway
Batch 5k by keyset, lock ascending, snapshot before, rollback command beside the migration.
● Production incidentPOST-MORTEMseverity: high

The Missing Join That Rewrote 4.2 Million Prices

Symptom
At 11:20, checkout latency spiked to 40s and the products table locked. A price-sync job meant to update 18k SKUs had rewritten all 4.2M product rows with a single repeated price. The site showed one price for everything — $9.99 blenders and $9.99 laptops alike. Rollback took 3 hours from the pre-deploy snapshot; 47 orders placed during the window needed manual price correction.
Assumption
The author tested the SET expression on 5 rows and assumed the WHERE clause 'looked right' — reviewers skimmed a 60-line migration and missed that the final version dropped the t.id = s.id join predicate during a rebase. Everyone assumed the staging run (which used a 1k-row fixture) proved safety at scale.
Root cause
The UPDATE..FROM listed the source table but the WHERE kept only the category filter, not the key join. Postgres then joined each target row to every source row and applied one arbitrary match per row (documented non-deterministic behavior). No transaction wrapped the migration, so 4.2M writes committed immediately and the snapshot rollback became the only path back.
Fix
Restored from snapshot, replayed 47 orders with corrected prices, and re-ran the fixed migration in batches of 5k with the join predicate plus a SELECT count(*) pre-check (18,204 rows expected, 18,204 matched). CI now requires every UPDATE..FROM migration to include its SELECT-first rowcount assertion and run inside a transaction with a dry-run EXPLAIN.
Key lesson
  • SELECT-first is non-negotiable: write the join as a SELECT, verify counts, then convert to UPDATE — missing predicates show up in counts before they show up in outages.
  • Wrap data migrations in transactions with rowcount guards; a migration that updates 4.2M rows when 18k were expected must abort itself.
Production debug guideFive cross-table update failures and the exact check for each.5 entries
Symptom · 01
UPDATE changed far more rows than expected (millions vs thousands)
Fix
Missing or wrong join predicate — the source cross-joined. Roll back, rewrite as SELECT t.id, s.val with the same FROM/WHERE, and compare counts. Add the key equality (t.id = s.id) before converting back.
Symptom · 02
Postgres error 'table specified more than once' / ambiguous column
Fix
You repeated the target table in FROM without aliasing for a self-join. Alias the target (UPDATE products AS p) and reference the alias consistently; never list the bare target name in FROM unless self-joining with an alias.
Symptom · 03
MySQL ERROR 1093: can't specify target table in FROM clause
Fix
MySQL forbids selecting from the updated table in a subquery. Rewrite as multi-table UPDATE..JOIN, or wrap the subquery in a derived table with an alias to force materialization.
Symptom · 04
Same UPDATE gives different values on rerun (non-determinism)
Fix
Multiple source rows match one target row — the engine picks one arbitrarily. Dedupe the source (GROUP BY key or DISTINCT ON) so each target matches at most one row, or switch to MERGE with WHEN MATCHED guards.
Symptom · 05
Correlated subquery sets NULLs where you expected values
Fix
Non-matching target rows get NULL from scalar subqueries. Add WHERE EXISTS (SELECT 1 ...) with the same correlation so only matched rows update — and decide explicitly what unmatched rows should keep.
UPDATE-from-SELECT Dialects Compared
DialectSyntax shapeMulti-matchAudit
PostgreSQLUPDATE t SET .. FROM s WHERE keySilent one-row pickRETURNING
SQL ServerUPDATE t SET .. FROM t JOIN sSilent one-row pickOUTPUT clause
MySQLUPDATE t JOIN s SET ..Updates per join rowROW_COUNT()
PortableSET = (subquery) + EXISTSError if >1 row (good)Pre-SELECT counts
MERGE (PG/MSSQL/Oracle)WHEN MATCHED THEN UPDATEErrors on dupes (good)OUTPUT/RETURNING
⚙ Quick Reference
3 commands from this guide
FileCommand / CodePurpose
pg_update_from.sqlSELECT p.id, p.price AS old, s.price AS newPattern 1
mssql_update_from.sqlBEGIN TRAN;Pattern 2
mysql_update_join.sqlSTART TRANSACTION;Pattern 3

Key takeaways

1
Write the SELECT first, verify counts and uniqueness, then convert to UPDATE.
2
Postgres
UPDATE..FROM; SQL Server: UPDATE alias..FROM JOIN; MySQL: UPDATE..JOIN..SET.
3
Portable fallback
scalar subquery SET plus matching EXISTS guard against NULL writes.
4
Dedupe sources to one row per key; MERGE errors loudly where UPDATE picks silently.
5
Batch by keyset, audit with RETURNING/ROWCOUNT, migrate off-peak with snapshot ready.

Common mistakes to avoid

4 patterns
×

Dropping the join predicate so the source cross-joins

Symptom
Millions of rows rewritten with one repeated value; long locks; frozen checkout.
Fix
SELECT-first with counts, keep t.key = s.key in WHERE/JOIN, abort if count mismatches.
×

Forgetting EXISTS so unmatched rows get NULL

Symptom
Thousands of prices nulled where no feed row existed — silent data loss.
Fix
Add WHERE EXISTS with the same correlation; decide unmatched-row policy explicitly.
×

Assuming one source row per key when duplicates exist

Symptom
Non-deterministic values; reruns give different prices with no error.
Fix
Pre-aggregate source (GROUP BY key) or use MERGE, which errors loudly on duplicates.
×

Running giant updates in one transaction at peak hours

Symptom
11-minute lock holds, blocked checkouts, painful rollbacks.
Fix
Keyset-batch 5k rows, commit per batch, run off-peak with snapshot ready.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
How do you update one table from another in PostgreSQL?
Q02SENIOR
Why does MySQL reject updating a table selected in a subquery?
Q03SENIOR
A rerun of your UPDATE gives different values with no error. Why?
Q01 of 03SENIOR

How do you update one table from another in PostgreSQL?

ANSWER
UPDATE t SET col = s.col FROM source s WHERE s.key = t.key plus filters, with RETURNING for audit. Always SELECT-first to verify counts, dedupe multi-match sources, and wrap in a transaction.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Can I UPDATE from a SELECT in one statement?
02
What happens with multiple source rows per target row?
03
How do I avoid NULLing unmatched rows?
04
Why does MySQL give ERROR 1093 on my UPDATE?
05
How do I update millions of rows without long locks?
N
Naren Founder & Principal Engineer

20+ years shipping high-throughput database systems. Notes here come from systems that actually shipped.

Follow
Verified
production tested
September 22, 2026
last updated
1,799
articles · all by Naren
🔥

That's SQL. Mark it forged?

3 min read · try the examples if you haven't

Previous
DuckDB Embedded Analytics
1 / 3 · SQL
Next
SQL ALTER TABLE Add Column