Homeβ€Ί Databaseβ€Ί SQL UPDATE Statement: Modify Data Safely Without Losing It

SQL UPDATE Statement: Modify Data Safely Without Losing It

Where developers are forged. Β· Structured learning Β· Free forever.
πŸ“ Part of: SQL Basics β†’ Topic 16 of 16
SQL UPDATE statement syntax, real examples, and battle-tested best practices.
πŸ§‘β€πŸ’» Beginner-friendly β€” no prior Database experience needed
In this tutorial, you'll learn:
  • Always run a SELECT with your exact WHERE clause before running the UPDATE. If the SELECT returns the right rows, your UPDATE hits the same rows. This one habit prevents more production incidents than any linter or code review.
  • An UPDATE without a WHERE clause has no safety net, no warning, and no undo β€” the database will cheerfully overwrite every row and tell you it succeeded. In MySQL, SET sql_safe_updates=1 at the session level makes the engine refuse non-keyed UPDATEs. Use it for any manual session touching production.
  • Wrap multi-row UPDATEs in explicit BEGIN...COMMIT transactions. The BEGIN is your pause button. Read the row count. Inspect a sample. If anything looks wrong, ROLLBACK costs you nothing. COMMIT without looking first costs you everything.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
⚑ Quick Answer
Picture a whiteboard in a busy office where someone has written every customer's delivery address with a dry-erase marker. The SQL UPDATE statement is the act of walking up to that whiteboard and changing one specific address β€” or a whole column of them β€” without erasing the entire board and starting over. The key is knowing exactly which entry you're changing before the marker touches the surface. If you forget to look at which line you're standing in front of, you overwrite every address on the board at once β€” and that's when the phones start ringing.

Someone on my team once ran an UPDATE against a 2-million-row orders table on a Friday afternoon without a WHERE clause. Every single order in the system got marked as 'shipped'. The support queue hit 4,000 tickets before Monday morning. That's not a cautionary tale I read somewhere β€” I personally helped write the rollback script at 11pm while eating cold pizza.

The SQL UPDATE statement is the most deceptively simple command in the language. The syntax fits on one line. A junior dev can write their first one in five minutes. And that's exactly the danger β€” because the same one-liner that fixes a customer's typo can silently destroy years of data if you're half a second careless. No confirmation dialog. No undo button unless you've planned for one. Just gone.

By the end of this, you'll be able to write UPDATE statements that change exactly the rows you intend β€” and zero others. You'll know how to preview your changes before committing them, how to update multiple columns at once, how to use data from another table to drive your update, and how to wrap it all in a transaction so you have an escape hatch when something goes sideways. These are the habits that separate developers who get paged at 3am from the ones who sleep through the night.

The Anatomy of UPDATE: What Every Clause Actually Does

Before you write a single UPDATE, understand what the database engine is doing under the hood. It's not magic β€” it's a three-step process: find the rows that match your condition, lock them so nobody else changes them mid-operation, then overwrite the specified columns with the new values. Miss any part of that mental model and you'll misread what your query is doing.

The full syntax is: UPDATE table_name SET column1 = value1, column2 = value2 WHERE condition. Three keywords. Each one is load-bearing. UPDATE names the table. SET lists every column you want to change and what to change it to. WHERE is the filter β€” the only thing standing between 'change this one row' and 'change every row in existence'. The WHERE clause isn't optional in theory, but most databases will happily let you omit it, and they will not warn you.

Think of SET like filling out a change-of-address form β€” you only list the fields that are changing. Every column you don't mention stays exactly as it was. This is different from replacing the whole row. A common beginner assumption is that UPDATE rewrites the entire record. It doesn't β€” it's surgical. Columns you ignore are untouched.

The real gotcha is evaluation order. The database evaluates WHERE first to identify the target rows, then applies SET. This means you can reference the current column value inside SET itself β€” UPDATE accounts SET balance = balance - 50.00 WHERE account_id = 9912 β€” and that reads the original balance, subtracts 50, and writes back the result. That pattern powers half of all financial transaction logic.

UserProfileUpdate.sql Β· SQL
1234567891011121314151617181920212223242526272829303132333435363738
-- io.thecodeforge β€” Database tutorial

-- Scenario: An e-commerce platform's user service needs to update
-- a customer's email address after they changed it in account settings.
-- This is the most basic UPDATE pattern β€” one row, multiple columns.

-- BEFORE: Always check what you're about to change.
-- Run this SELECT first using the same WHERE clause you plan to use in UPDATE.
-- If the SELECT returns the right rows, your UPDATE will hit the same rows.
SELECT
    user_id,
    email,
    display_name,
    updated_at
FROM users
WHERE user_id = 4821;
-- Output shows exactly one row. Good. Now we know the WHERE clause is precise.

-- NOW: Run the actual UPDATE.
-- SET lists only the columns that are changing β€” email, display_name, updated_at.
-- Every other column (password_hash, created_at, etc.) is left completely alone.
UPDATE users
SET
    email        = 'sarah.okonkwo@newdomain.com',  -- new email from the form
    display_name = 'Sarah O.',                      -- user also updated their display name
    updated_at   = NOW()                            -- always track when a row was last touched
WHERE
    user_id = 4821;  -- PRIMARY KEY filter β€” this can only ever match one row

-- Verify the change landed correctly.
-- Never assume an UPDATE worked β€” always read back the affected row.
SELECT
    user_id,
    email,
    display_name,
    updated_at
FROM users
WHERE user_id = 4821;
β–Ά Output
-- After the SELECT before UPDATE:
user_id | email | display_name | updated_at
---------+------------------------------+--------------+---------------------
4821 | sarah.k@olddomain.com | SarahK | 2024-08-14 09:22:11

-- After the UPDATE:
UPDATE 1
-- (PostgreSQL tells you how many rows were affected. MySQL shows: Rows matched: 1 Changed: 1)

-- After the verification SELECT:
user_id | email | display_name | updated_at
---------+------------------------------+--------------+---------------------
4821 | sarah.okonkwo@newdomain.com | Sarah O. | 2025-01-17 14:05:33
⚠️
Senior Shortcut: SELECT Before You UPDATEWrite the SELECT with your WHERE clause first. Run it. Count the rows. If the number matches your expectation, copy that exact WHERE clause into your UPDATE. This is the single habit that prevents the most production disasters. It takes 10 seconds and has saved me from catastrophe more than once.

Transactions and WHERE Clauses: Your Only Safety Net

Here's the hard truth about UPDATE: there's no built-in undo. The moment you hit enter, the database commits to making that change β€” unless you've explicitly told it not to yet. That's where transactions come in. A transaction is a box you draw around one or more SQL statements that says: 'treat all of this as one atomic unit β€” either all of it succeeds, or none of it does'.

You open a transaction with BEGIN (or START TRANSACTION in MySQL). Inside the transaction, your changes are visible to your current session but not yet permanent. At this point, you can still run ROLLBACK and the database rewinds everything back to exactly how it was before BEGIN. Once you run COMMIT, the changes become permanent and everyone else can see them. That's your window β€” BEGIN to COMMIT β€” and it's the only window you get.

I've seen teams skip transactions for 'simple' single-row UPDATEs and get burned when a network drop left a half-finished multi-step operation in a corrupted state. The cost of wrapping an UPDATE in a transaction is one extra line. The cost of not doing it during an incident is your entire evening.

The WHERE clause is your other layer of protection. Filtering by primary key is the gold standard β€” it's indexed, it's unique, and it can physically only match one row. When you're doing bulk updates, filter by a specific, narrow condition and always preview the count with SELECT COUNT() first. If you expect to touch 50 rows and SELECT COUNT() returns 50,000, you've found your bug before it cost you anything.

OrderStatusBulkUpdate.sql Β· SQL
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
-- io.thecodeforge β€” Database tutorial

-- Scenario: A fulfilment service needs to mark all orders from a specific
-- warehouse as 'on_hold' because that warehouse just lost power.
-- This is a bulk UPDATE β€” multiple rows affected β€” so we wrap it in a transaction
-- and audit the row count before committing.

-- Step 1: Start the transaction. Nothing is permanent until COMMIT.
BEGIN;

-- Step 2: Count exactly how many rows your WHERE clause will touch.
-- Run this before the UPDATE. Read the number. Does it match your expectation?
SELECT COUNT(*)
FROM orders
WHERE
    warehouse_id   = 'WH-DALLAS-03'  -- only orders from the affected warehouse
    AND order_status = 'processing'   -- only orders that haven't shipped yet
    AND created_at >= '2025-01-17';   -- only today's orders β€” don't touch historical data
-- Expected: ~80 rows. If this returns 80,000, STOP HERE. ROLLBACK. Re-examine the query.

-- Step 3: Run the UPDATE inside the same transaction.
-- Because we're inside BEGIN, this change is reversible until we hit COMMIT.
UPDATE orders
SET
    order_status   = 'on_hold',                          -- new status reflecting warehouse outage
    status_reason  = 'Warehouse WH-DALLAS-03 power outage', -- human-readable audit trail
    updated_at     = NOW(),                              -- timestamp the change
    updated_by     = 'ops_automation_service'            -- which system/user made this change
WHERE
    warehouse_id   = 'WH-DALLAS-03'
    AND order_status = 'processing'
    AND created_at >= '2025-01-17';
-- The database returns: UPDATE 84
-- 84 rows updated. This matched our expectation. Good.

-- Step 4: Inspect a sample of the updated rows before committing.
-- These will show the updated values because we're still inside the transaction.
SELECT order_id, order_status, status_reason, updated_at
FROM orders
WHERE warehouse_id = 'WH-DALLAS-03'
  AND order_status = 'on_hold'
  AND created_at >= '2025-01-17'
LIMIT 5;

-- Step 5: Only commit once you've confirmed the data looks correct.
-- Replace COMMIT with ROLLBACK if anything looks wrong β€” zero data is lost.
COMMIT;
-- After COMMIT: all 84 rows are permanently updated and visible to every connection.
β–Ά Output
-- After SELECT COUNT(*):
count
-------
84

-- After UPDATE:
UPDATE 84

-- After the sample SELECT (5 rows shown):
order_id | order_status | status_reason | updated_at
-----------+--------------+------------------------------------------+---------------------
ORD-98821 | on_hold | Warehouse WH-DALLAS-03 power outage | 2025-01-17 14:12:05
ORD-98834 | on_hold | Warehouse WH-DALLAS-03 power outage | 2025-01-17 14:12:05
ORD-98851 | on_hold | Warehouse WH-DALLAS-03 power outage | 2025-01-17 14:12:05
ORD-98867 | on_hold | Warehouse WH-DALLAS-03 power outage | 2025-01-17 14:12:05
ORD-98902 | on_hold | Warehouse WH-DALLAS-03 power outage | 2025-01-17 14:12:05

-- After COMMIT:
COMMIT
⚠️
Production Trap: Forgetting AUTOCOMMIT is ON by DefaultMySQL and MariaDB run in AUTOCOMMIT mode by default β€” every statement that isn't inside an explicit BEGIN...COMMIT is immediately permanent. A bare UPDATE without BEGIN has no rollback option whatsoever. Explicitly start transactions with BEGIN for any UPDATE that touches more than one row, or set AUTOCOMMIT=0 at the session level for scripts that run bulk operations.

UPDATE With JOIN and Subqueries: When One Table Isn't Enough

Simple single-table UPDATEs take you a long way, but real applications constantly need to update rows in one table based on data that lives in another. Think about marking products as 'discontinued' based on supplier records, or syncing a user's subscription tier from a payments table into a profiles table. The data driving the change is in table B, but the rows you need to modify are in table A.

There are two ways to do this: UPDATE with a JOIN (MySQL, SQL Server, PostgreSQL via FROM) or UPDATE with a correlated subquery (works everywhere). The JOIN syntax is more readable and almost always faster because the query planner can optimize a join more aggressively than it can a subquery that re-executes per row.

PostgreSQL uses a slightly different syntax β€” instead of a JOIN keyword directly in the UPDATE, you add a FROM clause and then JOIN conditions in WHERE. MySQL uses UPDATE table1 JOIN table2 ON ... SET. SQL Server uses UPDATE with a FROM and JOIN. They're all doing the same thing β€” don't let the syntax variation throw you.

The subquery approach works on every database without modification, which makes it useful for scripts that need to run cross-platform. The performance difference matters at scale β€” I've seen a subquery-based update crawl for 40 minutes on 500k rows that a JOIN-based equivalent finished in 90 seconds. Always check the query plan with EXPLAIN before running a bulk UPDATE against a large table.

SubscriptionTierSync.sql Β· SQL
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455
-- io.thecodeforge β€” Database tutorial

-- Scenario: A SaaS billing service processes monthly renewals.
-- When a payment succeeds, the payments table is updated first.
-- We then need to sync the subscription_tier in user_profiles
-- to match what the customer actually paid for this month.
-- The driving data (new tier) lives in payments β€” the rows to update live in user_profiles.

-- ── APPROACH 1: UPDATE with FROM + JOIN (PostgreSQL syntax) ──────────────────
-- Preferred: readable, and the query planner handles this efficiently.

UPDATE user_profiles AS up          -- alias the target table for readability
SET
    subscription_tier = p.new_tier,           -- take the tier from the payments row
    tier_valid_until  = p.next_billing_date,  -- set expiry from billing cycle in payments
    updated_at        = NOW()
FROM payments AS p                            -- this is the source of truth for the new tier
WHERE
    up.user_id    = p.user_id                 -- join condition: link profiles to their payment
    AND p.payment_status = 'succeeded'        -- only sync rows where payment actually went through
    AND p.billing_cycle  = '2025-01'          -- only this month's billing run β€” never re-process old data
    AND p.processed_at  >= NOW() - INTERVAL '1 hour'; -- safety window: only the last hour's payments
-- This updates ONLY the profile rows for users who just paid successfully.


-- ── APPROACH 2: Correlated Subquery (works on MySQL, SQLite, and everywhere else) ──
-- Use this when you need cross-database compatibility.
-- Slower at scale β€” always EXPLAIN this before running on large tables.

UPDATE user_profiles
SET
    subscription_tier = (
        -- This subquery runs once per row in user_profiles that matches the outer WHERE.
        -- It fetches the new_tier from the corresponding payment record.
        SELECT p.new_tier
        FROM payments AS p
        WHERE
            p.user_id        = user_profiles.user_id  -- correlated: links back to the outer row
            AND p.payment_status = 'succeeded'
            AND p.billing_cycle  = '2025-01'
        ORDER BY p.processed_at DESC
        LIMIT 1  -- defensive: take the most recent payment if somehow there are duplicates
    ),
    updated_at = NOW()
WHERE
    -- Only attempt the update for users who actually have a matching payment record.
    -- Without this EXISTS check, users with no payment this month get NULL written to subscription_tier.
    EXISTS (
        SELECT 1
        FROM payments AS p
        WHERE
            p.user_id        = user_profiles.user_id
            AND p.payment_status = 'succeeded'
            AND p.billing_cycle  = '2025-01'
    );
β–Ά Output
-- Approach 1 (PostgreSQL FROM + JOIN):
UPDATE 1847
-- 1,847 user profiles updated to reflect this month's successful payments.

-- Approach 2 (Correlated Subquery):
UPDATE 1847
-- Same result, but will run noticeably slower on large tables.
-- Always run EXPLAIN ANALYZE on Approach 2 before using it in production.
⚠️
Never Do This: UPDATE Without an EXISTS Guard on SubqueriesIf you use a correlated subquery in SET and a user has no matching row in the source table, the subquery returns NULL β€” and NULL gets written silently into the column. No error. No warning. Users who didn't pay this month get their subscription_tier set to NULL. Always pair a subquery-based UPDATE with a WHERE EXISTS(...) clause that mirrors the subquery condition. This is the most common bulk-update bug I've ever debugged.

Production Patterns: RETURNING, Audit Trails, and When UPDATE Isn't the Answer

Two things separate production-grade UPDATE usage from textbook examples: getting data back out of the UPDATE operation itself, and knowing when not to use UPDATE at all.

PostgreSQL and SQL Server support RETURNING (or OUTPUT in SQL Server) β€” a clause that makes UPDATE hand back the modified rows immediately. This eliminates the extra SELECT you'd otherwise need to fetch the updated state, removes a round-trip to the database, and β€” critically β€” guarantees you're reading exactly the rows that were just modified, not a slightly stale version. In a high-concurrency system, that gap between UPDATE and SELECT is not theoretical. I've seen race conditions in checkout services where another request updated the row in that gap, and the verification SELECT read the second update, not the first.

On audit trails: never trust application-layer logging to record what actually changed in the database. Application logs don't capture changes made by migration scripts, admin tools, or direct database access. The only reliable audit trail is built at the database layer β€” a combination of an updated_at timestamp column, an updated_by column, and for high-compliance systems, a shadow audit table populated by a trigger or Change Data Capture. If your system handles financial, medical, or legal data and you don't have this, you have a compliance gap.

Finally β€” UPDATE is not always the right tool. If you're soft-deleting records, the pattern of updating an is_deleted flag works fine at small scale but becomes a performance burden at millions of rows because every query that 'ignores' deleted rows still has to scan past them. Sometimes a separate archive table with INSERT is the better architectural choice. Don't let familiarity with UPDATE make you reach for it in every situation.

InventoryReservation.sql Β· SQL
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475
-- io.thecodeforge β€” Database tutorial

-- Scenario: A checkout service needs to decrement inventory when an item is added to cart.
-- Two problems to solve:
--   1. We need the updated stock level immediately (for the API response) β€” no second SELECT.
--   2. We must never go below zero β€” this is a safety constraint.
-- RETURNING solves problem 1. A WHERE guard solves problem 2.

-- ── PATTERN: Atomic decrement with RETURNING (PostgreSQL) ────────────────────
-- This is the pattern inside every inventory and rate-limiter service.
-- The whole thing is one round-trip. No race condition between UPDATE and SELECT.

UPDATE product_inventory
SET
    quantity_available = quantity_available - 1,  -- decrement by exactly 1 unit
    quantity_reserved  = quantity_reserved  + 1,  -- track how many are in-cart but not purchased
    last_reserved_at   = NOW()
WHERE
    product_id         = 'SKU-00441'              -- specific product
    AND quantity_available > 0                    -- CRITICAL: never decrement below zero
                                                  -- If stock is 0, this WHERE fails to match,
                                                  -- UPDATE touches 0 rows, and RETURNING returns nothing.
                                                  -- The application reads empty result = out of stock.
RETURNING
    product_id,
    quantity_available,   -- this is the UPDATED value, already decremented
    quantity_reserved,    -- already incremented
    last_reserved_at;
    -- No second SELECT needed. This is the post-update state.


-- ── PATTERN: Audit trail with a shadow table (PostgreSQL trigger approach) ────
-- For compliance systems: every UPDATE writes a before-image to an audit log.
-- This is the database-layer safety net that catches admin tool changes
-- and migration-script changes that never touch your app logs.

-- First, the audit table structure:
CREATE TABLE IF NOT EXISTS user_profiles_audit (
    audit_id        BIGSERIAL PRIMARY KEY,
    user_id         INT          NOT NULL,
    changed_at      TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
    changed_by      TEXT         NOT NULL,  -- application user or DB user
    operation       TEXT         NOT NULL,  -- 'UPDATE', 'DELETE'
    old_email       TEXT,                   -- value BEFORE the change
    new_email       TEXT,                   -- value AFTER the change
    old_tier        TEXT,
    new_tier        TEXT
);

-- The trigger fires automatically after every UPDATE on user_profiles.
-- You don't call this manually β€” the database calls it.
CREATE OR REPLACE FUNCTION log_user_profile_changes()
RETURNS TRIGGER AS $$
BEGIN
    -- OLD = the row values before the UPDATE
    -- NEW = the row values after the UPDATE
    INSERT INTO user_profiles_audit (
        user_id, changed_by, operation, old_email, new_email, old_tier, new_tier
    ) VALUES (
        OLD.user_id,
        current_user,      -- the DB role that ran the UPDATE
        'UPDATE',
        OLD.email,         -- what it was before
        NEW.email,         -- what it became
        OLD.subscription_tier,
        NEW.subscription_tier
    );
    RETURN NEW;  -- required: return the new row to allow the UPDATE to proceed
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_user_profile_audit
    AFTER UPDATE ON user_profiles       -- fires after every UPDATE, not before
    FOR EACH ROW                        -- once per affected row, not once per statement
    EXECUTE FUNCTION log_user_profile_changes();
β–Ά Output
-- After the inventory RETURNING query (item was in stock):
product_id | quantity_available | quantity_reserved | last_reserved_at
------------+--------------------+-------------------+------------------------
SKU-00441 | 11 | 3 | 2025-01-17 14:18:42+00
-- Returned 1 row: stock went from 12 to 11. Application confirms reservation succeeded.

-- If quantity_available was already 0:
product_id | quantity_available | quantity_reserved | last_reserved_at
------------+--------------------+-------------------+------------------------
(0 rows)
-- Zero rows returned = out of stock. Application shows 'item unavailable'.
-- No row was modified. The check is atomic β€” impossible to race this to -1.

-- Trigger creation:
CREATE TRIGGER
-- From this point on, every UPDATE on user_profiles automatically writes to user_profiles_audit.
πŸ”₯
Interview Gold: RETURNING Eliminates a Race ConditionUPDATE ... RETURNING gives you the post-update row state in a single atomic operation. The alternative β€” UPDATE then SELECT β€” has a gap where another transaction can modify the row between the two statements. In a concurrent checkout or inventory system, that race condition is real and will eventually fire. RETURNING is the correct pattern, not a nice-to-have.
AspectUPDATE with JOIN / FROMUPDATE with Correlated Subquery
ReadabilityClear β€” join logic is explicit and familiarModerate β€” the self-reference is non-obvious to beginners
Performance on large tablesFast β€” query planner optimises joins aggressivelySlow β€” subquery can execute once per matched row
Database compatibilitySyntax varies (MySQL, PostgreSQL, SQL Server all differ slightly)Works identically on MySQL, PostgreSQL, SQLite, SQL Server
NULL risk when no match foundNo β€” unmatched rows are simply excluded by the joinYes β€” missing match returns NULL unless protected by WHERE EXISTS
Best used whenYou control the database engine and need performanceYou need one script to run on multiple database types
EXPLAIN complexitySingle pass, merge or hash join visible in planNested loop visible β€” immediately flags performance issues

🎯 Key Takeaways

  • Always run a SELECT with your exact WHERE clause before running the UPDATE. If the SELECT returns the right rows, your UPDATE hits the same rows. This one habit prevents more production incidents than any linter or code review.
  • An UPDATE without a WHERE clause has no safety net, no warning, and no undo β€” the database will cheerfully overwrite every row and tell you it succeeded. In MySQL, SET sql_safe_updates=1 at the session level makes the engine refuse non-keyed UPDATEs. Use it for any manual session touching production.
  • Wrap multi-row UPDATEs in explicit BEGIN...COMMIT transactions. The BEGIN is your pause button. Read the row count. Inspect a sample. If anything looks wrong, ROLLBACK costs you nothing. COMMIT without looking first costs you everything.
  • UPDATE ... RETURNING is not just a convenience β€” it closes a real concurrency gap. The time between a bare UPDATE and a follow-up SELECT is measurable in a production system under load. Any concurrent transaction can modify that row in the gap. RETURNING gives you the post-update state atomically, in one round-trip, with no race condition possible.

⚠ Common Mistakes to Avoid

  • βœ•Mistake 1: Writing UPDATE without a WHERE clause β€” every row in the table gets overwritten silently β€” the database returns 'UPDATE 2000000' with no error β€” immediately run a transaction: BEGIN before any bulk UPDATE, verify row count with SELECT COUNT(*) first, and enable safe-update mode in MySQL with SET sql_safe_updates=1 which rejects UPDATEs without a WHERE on a key column.
  • βœ•Mistake 2: Using a correlated subquery in SET without a WHERE EXISTS guard β€” rows with no matching record in the source table get NULL written into the target column β€” no error thrown, no warning, just silent data corruption β€” always mirror the subquery in a WHERE EXISTS(...) clause with identical join conditions so non-matching rows are excluded entirely.
  • βœ•Mistake 3: Running a bulk UPDATE outside a transaction assuming it is safe because it is 'just one statement' β€” partial failures mid-statement (disk full, lock timeout, connection drop) can leave the table in a half-updated state β€” always wrap multi-row UPDATEs in explicit BEGIN...COMMIT and set a statement timeout (SET statement_timeout = '30s' in PostgreSQL) so runaway updates do not hold locks indefinitely.
  • βœ•Mistake 4: Updating a non-indexed column in the WHERE clause of a bulk UPDATE β€” the database performs a full sequential scan to find target rows, locking the table progressively and blocking all concurrent reads and writes β€” symptom is 'Lock wait timeout exceeded' errors in application logs β€” always run EXPLAIN on the UPDATE query before executing it in production and add an index on the WHERE column if a sequential scan appears in the plan.

Interview Questions on This Topic

  • QYou run an UPDATE that touches 500,000 rows in a PostgreSQL table. Halfway through, another transaction is trying to read from the same table. Describe exactly what locking behaviour occurs, whether the read is blocked, and how you would structure the bulk UPDATE differently to reduce lock contention.
  • QYour team proposes using a correlated subquery versus a FROM-with-JOIN in a nightly batch UPDATE that syncs 300,000 rows. Walk me through how you would make the performance comparison decision, what you would look for in EXPLAIN ANALYZE output, and when you would choose the subquery despite it being slower.
  • QYou discover that an UPDATE ran successfully β€” the database returned 'UPDATE 1' β€” but the application is still displaying the old value. There was no error. What are three distinct root causes you would investigate, and what SQL would you run to diagnose each one?

Frequently Asked Questions

What happens if I run an SQL UPDATE without a WHERE clause?

Every single row in the table gets updated with the new values β€” no exceptions, no warnings. The database treats a missing WHERE clause as 'match everything', which is valid SQL. Your only recovery path is a transaction rollback if you started one, or a restore from backup if you didn't. Always write the WHERE clause before the SET clause so you can't accidentally execute an incomplete UPDATE.

What's the difference between UPDATE and INSERT in SQL?

UPDATE modifies columns in rows that already exist. INSERT creates new rows that did not exist before. The rule of thumb: if the row is already in the table and you need to change something about it, use UPDATE. If the data is brand new and has no existing row, use INSERT. Some databases also offer UPSERT (INSERT ... ON CONFLICT DO UPDATE in PostgreSQL, or MERGE in SQL Server) when you're not sure whether the row exists yet.

How do I update a column value based on its current value in SQL?

Reference the column name on the right side of the SET expression: UPDATE accounts SET balance = balance - 50.00 WHERE account_id = 9912. The database reads the current value of balance, subtracts 50, and writes the result back. This is safe inside a single statement because the WHERE evaluation and the SET happen atomically β€” no other transaction can change balance between those two steps within the same statement execution.

Can an SQL UPDATE statement cause a deadlock in a high-traffic production system?

Yes, and it's more common than people expect. Deadlocks happen when two concurrent transactions each hold a lock the other needs β€” Transaction A locks row 1 then tries for row 2, Transaction B locks row 2 then tries for row 1. The database detects the cycle and kills one transaction with 'ERROR: deadlock detected'. The mitigation is consistent lock ordering β€” ensure all transactions that touch multiple rows always acquire locks in the same sequence. In PostgreSQL, SELECT ... FOR UPDATE in a consistent order before your UPDATE statements is the standard pattern. Also keep transactions short: the longer a transaction holds locks, the wider the window for a deadlock to form.

πŸ”₯
Naren Founder & Author

Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.

← PreviousSQL String Functions
Forged with πŸ”₯ at TheCodeForge.io β€” Where Developers Are Forged