Back to Article List

UPDATE MSSQL query: syntax, joins and OUTPUT

UPDATE MSSQL query: syntax, joins and OUTPUT

Updated and tested 13 October 2026

An UPDATE overwrites the old values and carries the evidence off with them. So write it as a SELECT first. Same FROM, same WHERE, and swap in the UPDATE once the rows coming back are the rows you meant. On a statement you are sure about that costs ten seconds. On the one you should never have run, it saves your afternoon.

Every query below runs against the same three tables. The other T-SQL tutorials on this site reuse them, so the examples stack up as you go and no article starts from scratch.

CREATE TABLE dbo.customers (
    customer_id  INT IDENTITY(1,1) NOT NULL PRIMARY KEY,
    email        VARCHAR(255)  NOT NULL UNIQUE,
    company_name NVARCHAR(200) NULL,
    country_code CHAR(2)       NOT NULL,
    created_at   DATETIME2(3)  NOT NULL DEFAULT SYSUTCDATETIME()
);

CREATE TABLE dbo.orders (
    order_id     INT IDENTITY(1,1) NOT NULL PRIMARY KEY,
    customer_id  INT           NOT NULL REFERENCES dbo.customers(customer_id),
    order_status VARCHAR(20)   NOT NULL DEFAULT 'pending',
    order_total  DECIMAL(10,2) NOT NULL,
    discount_pct DECIMAL(5,2)  NULL,
    placed_at    DATETIME2(3)  NOT NULL,
    shipped_at   DATETIME2(3)  NULL
);

CREATE TABLE dbo.order_items (
    order_item_id INT IDENTITY(1,1) NOT NULL PRIMARY KEY,
    order_id      INT           NOT NULL REFERENCES dbo.orders(order_id),
    sku           VARCHAR(40)   NOT NULL,
    quantity      INT           NOT NULL,
    unit_price    DECIMAL(10,2) NOT NULL
);

A basic UPDATE MSSQL query with a WHERE clause

Three lines. One of them decides your afternoon.

UPDATE dbo.orders
SET order_status = 'shipped',
    shipped_at   = SYSUTCDATETIME()
WHERE order_id = 4821;

Multiple columns go in one SET list, separated by commas. Every assignment on the right side sees the pre-update value of the row, so SET a = b, b = a swaps two columns correctly in a single statement. That surprises people arriving from procedural languages.

Before running that, run this:

SELECT order_id, order_status, shipped_at
FROM dbo.orders
WHERE order_id = 4821;

Same table, same predicate, no damage. You get the row count and the current values, and a wrong predicate costs you nothing at this point. Getting predicates right is most of the job, and every operator in a SQL Server WHERE clause has its own way of misfiring. I do this even for single-row updates by primary key. The cost is two seconds. The failure mode is a restore.

An UPDATE with no WHERE clause updates every row. SQL Server will not warn you or cap it at some sensible number. SSMS has no equivalent of the MySQL client's safe-updates mode, so the guard rail is your own habit.

The fixed clause order: SET, OUTPUT, FROM, WHERE

T-SQL's UPDATE grammar is rigid. Clause order is what people arriving from another engine get wrong first, and it is the same for every UPDATE MSSQL query no matter how many tables are involved:

UPDATE [ TOP ( expression ) [ PERCENT ] ] table_or_alias
    SET column_name = { expression | DEFAULT | NULL } [ ,...n ]
    [ <OUTPUT Clause> ]
    [ FROM { <table_source> } [ ,...n ] ]
    [ WHERE <search_condition> ]
    [ OPTION ( <query_hint> [ ,...n ] ) ];

SET comes first, always. MySQL lets you write the join before SET (UPDATE orders o JOIN customers c ON ... SET ...) and that muscle memory produces this in SQL Server:

Msg 156, Level 15, State 1
Incorrect syntax near the keyword 'FROM'.

Same message for OUTPUT placed after FROM, or WHERE placed before it. The parser matches a fixed clause sequence and makes no attempt to work out what you meant. Microsoft's UPDATE reference prints that sequence at the top of the page, and reading it once is enough for the shape to stick. Memorise SET, OUTPUT, FROM, WHERE and the syntax errors stop.

UPDATE with JOIN using the FROM clause

To update one table using values from another, name the alias on the UPDATE line and define that alias in FROM:

UPDATE o
SET o.order_status = 'flagged'
FROM dbo.orders AS o
JOIN dbo.customers AS c ON c.customer_id = o.customer_id
WHERE c.country_code = 'IR'
  AND o.order_status = 'pending';

Only dbo.orders is modified. The customers table is read-only here, which is the whole point of the FROM extension: filter and pull values from anywhere, write to exactly one table.

Name the table as UPDATE dbo.orders on the first line while also aliasing it as o in the FROM clause. SQL Server reads that as two separate references to dbo.orders. Then it complains about duplicate exposed names. Alias it once and target the alias.

Non-deterministic joins give undefined results with no error

This is the behaviour I would change if I got one wish. It is also why an UPDATE MSSQL command that reads from a table I did not build gets checked before it runs. Microsoft's own wording: "The results of an UPDATE statement are undefined if the statement includes a FROM clause that isn't specified in such a way that only one value is available for each column occurrence that is updated, that is if the UPDATE statement isn't deterministic."

Read that again with the emphasis on undefined. There's no error and no warning. Oracle refuses the same statement with ORA-30926 when two source rows match one target row. PostgreSQL's UPDATE ... FROM has SQL Server's gap, and its documentation says which join row gets used "is not readily predictable". SQL Server picks one of them and moves on.

A price feed lands twice in the same day:

CREATE TABLE dbo.price_feed (
    sku        VARCHAR(40)   NOT NULL,
    unit_price DECIMAL(10,2) NOT NULL,
    feed_at    DATETIME2(3)  NOT NULL
);

INSERT INTO dbo.price_feed (sku, unit_price, feed_at)
VALUES ('LD-RAM-32', 129.00, '2026-08-25T09:00:00'),
       ('LD-RAM-32', 139.00, '2026-08-25T17:30:00');

Then the obvious sync:

UPDATE oi
SET oi.unit_price = pf.unit_price
FROM dbo.order_items AS oi
JOIN dbo.price_feed AS pf ON pf.sku = oi.sku;
(37 rows affected)

Thirty-seven rows, zero errors, and every one of them now holds either 129.00 or 139.00 depending on which row the plan happened to read last. Change the plan (add an index, restore onto a machine with a different core count, let the statistics age) and the same statement produces the other answer. It is repeatable enough to pass your test and unstable enough to fail in production six weeks later.

Check the join key for duplicates before you run

The detection query takes five seconds and I run it on any source table I didn't build myself:

SELECT sku, COUNT(*) AS row_count
FROM dbo.price_feed
GROUP BY sku
HAVING COUNT(*) > 1;
sku          row_count
-----------  ---------
LD-RAM-32    2

One row back means the join is non-deterministic. Zero rows back means the key is unique in the source and the UPDATE has exactly one candidate per target row. Decide which duplicate should win and make that explicit:

WITH latest_price AS (
    SELECT sku,
           unit_price,
           ROW_NUMBER() OVER (PARTITION BY sku ORDER BY feed_at DESC) AS rn
    FROM dbo.price_feed
)
UPDATE oi
SET oi.unit_price = lp.unit_price
FROM dbo.order_items AS oi
JOIN latest_price AS lp
  ON lp.sku = oi.sku
 AND lp.rn = 1;

Now 139.00 wins because you picked it. The cost is four extra lines.

Compound assignment operators

T-SQL supports +=, -=, *=, /=, %=, &=, ^= and |= in a SET list:

UPDATE dbo.orders
SET order_total += 4.99
WHERE order_id = 4821;

That is the same thing as SET order_total = order_total + 4.99. On character data += concatenates. Every operator in that list takes one expression on its right. A compound assignment can sit in the same SET list as ordinary assignments, in any order. Each target row is written once, with one value.

The NULL rule still applies. Wrapping the column is the fix, and the options for doing that are ISNULL, COALESCE and CASE. SET discount_pct += 5 on a row where discount_pct is NULL leaves it NULL, since NULL plus anything is NULL. The same holds for every compound operator above.

UPDATE TOP and why it needs a subquery

UPDATE accepts TOP, and the syntax needs the parentheses:

UPDATE TOP (100) dbo.orders
SET order_status = 'archived'
WHERE order_status = 'closed';

Which hundred rows? Unspecified. Microsoft is blunt about it: "The rows referenced in the TOP expression used with INSERT, UPDATE, or DELETE aren't arranged in any order." There's nowhere in that syntax to put an ORDER BY, so if you need the oldest hundred and not an arbitrary hundred, pick them in a subquery and join back:

UPDATE o
SET o.order_status = 'archived'
FROM dbo.orders AS o
JOIN (
    SELECT TOP (100) order_id
    FROM dbo.orders
    WHERE order_status = 'closed'
    ORDER BY placed_at
) AS batch ON batch.order_id = o.order_id;

For a plain "process rows in chunks until they're gone" loop, bare UPDATE TOP (n) is fine and the arbitrary order costs nothing. Order it when the choice of rows is visible to a human.

PERCENT is in the grammar as well. This paragraph is thinner than it should be because PERCENT on an UPDATE turns up about once every few years, and I can't tell you how it behaves on a table big enough to care.

UPDATE with OUTPUT: inserted and deleted

The OUTPUT clause hands you the rows the statement touched, with deleted. holding the pre-update values and inserted. the post-update values. Same pseudo-tables a trigger sees.

UPDATE dbo.orders
SET order_status = 'refunded'
OUTPUT deleted.order_id,
       deleted.order_status  AS old_status,
       inserted.order_status AS new_status,
       SYSUTCDATETIME()      AS changed_at
WHERE order_id = 4821;

One statement, one receipt. Timestamps in these examples come from SYSUTCDATETIME() and not GETDATE(), a preference SQL Server date functions makes the case for at length. The receipt is what turns a risky UPDATE into something reviewable afterwards, and it costs one clause.

OUTPUT works the same way on INSERT and DELETE.

OUTPUT INTO an audit table

Send the receipt somewhere permanent by adding INTO:

CREATE TABLE dbo.order_status_audit (
    order_id   INT          NOT NULL,
    old_status VARCHAR(20)  NOT NULL,
    new_status VARCHAR(20)  NOT NULL,
    changed_at DATETIME2(3) NOT NULL,
    changed_by SYSNAME      NOT NULL
);

UPDATE dbo.orders
SET order_status = 'refunded'
OUTPUT deleted.order_id,
       deleted.order_status,
       inserted.order_status,
       SYSUTCDATETIME(),
       SUSER_SNAME()
INTO dbo.order_status_audit (order_id, old_status, new_status, changed_at, changed_by)
WHERE order_status = 'disputed'
  AND placed_at < '20260101';

Note where INTO sits: still in the OUTPUT slot, before FROM and WHERE. Every restriction on it is listed in the OUTPUT clause reference. The ones that come up:

  • OUTPUT without INTO fails outright if the target table has enabled triggers for that action.
  • The INTO target can't have triggers, can't be on either side of a foreign key, can't carry CHECK constraints and can't have enabled rules. An audit table with no constraints on it, in other words.
  • Row order into the output table is not guaranteed, so add your own ordering column if order matters.
  • OUTPUT to the client or to a table variable forces a serial plan, which is measurable on large statements.
  • Rows are returned to the client even when the statement later errors and rolls back, so a returned row is not proof of a committed row.

OUTPUT is also unavailable against remote tables and views, which is the usual reason it fails on a linked-server statement that works fine locally.

UPDATE from a CTE or a derived table

A common table expression can be the target of the UPDATE as long as it resolves to one updatable base table:

WITH order_totals AS (
    SELECT order_id,
           SUM(quantity * unit_price) AS calculated_total
    FROM dbo.order_items
    GROUP BY order_id
)
UPDATE o
SET o.order_total = ot.calculated_total
FROM dbo.orders AS o
JOIN order_totals AS ot ON ot.order_id = o.order_id
WHERE o.order_total <> ot.calculated_total;

The GROUP BY makes that join deterministic by construction, which is why I aggregate in a CTE first. Join straight to a detail table and you lose that guarantee. One row per order_id, guaranteed by the grammar.

Two things about CTEs here. Common table expressions have an article of their own and it covers the second one properly. A statement before a WITH needs a terminating semicolon, which is where the defensive ;WITH style comes from. And a CTE isn't a temp table: reference it twice and the query underneath runs twice.

The derived-table form does the same job inline:

UPDATE o
SET o.order_total = ot.calculated_total
FROM dbo.orders AS o
JOIN (
    SELECT order_id, SUM(quantity * unit_price) AS calculated_total
    FROM dbo.order_items
    GROUP BY order_id
) AS ot ON ot.order_id = o.order_id
WHERE o.order_total <> ot.calculated_total;

Identical plan. The nesting is what makes the second form hard to read once a query has any depth to it, so I take the CTE.

Your idea deserves better hosting

24/7 support 30-day money-back guaranteeRefund covers the VPS plan on your first order. Cancel anytime
مدة الإشتراك

VPS.S1

23.26 zł Save  17 %
19.38 zł شهري
  • 2 vCPU AMD EPYC
  • 2 GB RAMذاكرة
  • 30 GB NVMeتخزين
  • نطاق ترددي غير محدود
  • IPv4 & IPv6دعم IPv6 غير متاح حاليًا في فرنسا وفنلندا أو هولندا. مشمول

VPS.S3

58.21 zł Save  33 %
38.80 zł شهري
  • 4 vCPU AMD EPYC
  • 6 GB RAMذاكرة
  • 70 GB NVMeتخزين
  • نطاق ترددي غير محدود
  • IPv4 & IPv6دعم IPv6 غير متاح حاليًا في فرنسا وفنلندا أو هولندا. مشمول

VPS.S4

77.63 zł Save  20 %
62.10 zł شهري
  • 6 vCPU AMD EPYC
  • 8 GB RAMالذاكرة
  • 80 GB NVMeالتخزين
  • نطاق ترددي غير محدود
  • IPv4 & IPv6دعم IPv6 غير متاح حاليًا في فرنسا وفنلندا أو هولندا. مشمول

EPYC VPS.P1

34.91 zł Save  22 %
27.15 zł شهري
  • 2 vCPU AMD EPYC
  • 4 GB RAMذاكرة
  • 40 GB NVMeتخزين
  • نطاق ترددي غير محدود
  • IPv4 & IPv6دعم IPv6 غير متاح حاليًا في فرنسا وفنلندا أو هولندا. مشمول
  • نسخ احتياطي تلقائي مجانييشمل خانة نسخ احتياطي واحدة يمكنك ضبطها للتشغيل يوميًا أو أسبوعيًا أو شهريًا.

EPYC VPS.P2

65.98 zł Save  24 %
50.45 zł شهري
  • 2 vCPU AMD EPYC
  • 8 GB RAMذاكرة
  • 80 GB NVMeتخزين
  • نطاق ترددي غير محدود
  • IPv4 & IPv6دعم IPv6 غير متاح حاليًا في فرنسا وفنلندا أو هولندا. مشمول
  • نسخ احتياطي تلقائي مجانييشمل خانة نسخ احتياطي واحدة يمكنك ضبطها للتشغيل يوميًا أو أسبوعيًا أو شهريًا.

EPYC VPS.P4

116.46 zł Save  23 %
89.28 zł شهري
  • 4 vCPU AMD EPYC
  • 16 GB RAMذاكرة
  • 160 GB NVMeتخزين
  • نطاق ترددي غير محدود
  • IPv4 & IPv6دعم IPv6 غير متاح حاليًا في فرنسا وفنلندا أو هولندا. مشمول
  • نسخ احتياطي تلقائي مجانييشمل خانة نسخ احتياطي واحدة يمكنك ضبطها للتشغيل يوميًا أو أسبوعيًا أو شهريًا.

EPYC VPS.P5

155.30 zł Save  25 %
116.46 zł شهري
  • 8 vCPU AMD EPYC
  • 16 GB RAMذاكرة
  • 180 GB NVMeتخزين
  • نطاق ترددي غير محدود
  • IPv4 & IPv6دعم IPv6 غير متاح حاليًا في فرنسا وفنلندا أو هولندا. مشمول
  • نسخ احتياطي تلقائي مجانييشمل خانة نسخ احتياطي واحدة يمكنك ضبطها للتشغيل يوميًا أو أسبوعيًا أو شهريًا.

EPYC VPS.P6

232.97 zł Save  25 %
174.72 zł شهري
  • 8 vCPU AMD EPYC
  • 32 GB RAMذاكرة
  • 200 GB NVMeتخزين
  • نطاق ترددي غير محدود
  • IPv4 & IPv6دعم IPv6 غير متاح حاليًا في فرنسا وفنلندا أو هولندا. مشمول
  • نسخ احتياطي تلقائي مجانييشمل خانة نسخ احتياطي واحدة يمكنك ضبطها للتشغيل يوميًا أو أسبوعيًا أو شهريًا.

EPYC VPS.P7

271.80 zł Save  29 %
194.13 zł شهري
  • 16 vCPU AMD EPYC
  • 32 GB RAMذاكرة
  • 240 GB NVMeتخزين
  • نطاق ترددي غير محدود
  • IPv4 & IPv6دعم IPv6 غير متاح حاليًا في فرنسا وفنلندا أو هولندا. مشمول
  • نسخ احتياطي تلقائي مجانييشمل خانة نسخ احتياطي واحدة يمكنك ضبطها للتشغيل يوميًا أو أسبوعيًا أو شهريًا.

Genoa VPS.G2

97.05 zł Save  20 %
77.63 zł شهري
  • 2 vCPUمعالج AMD EPYC Genoa من الجيل الرابع 9xx4 بسرعة 3.25 GHz أو ما يماثله، على معمارية Zen 4. AMD EPYC G4
  • 4 GB DDR5ذاكرة
  • 50 GB NVMeتخزين
  • نطاق ترددي غير محدود
  • IPv4 & IPv6دعم IPv6 غير متاح حاليًا في فرنسا وفنلندا أو هولندا. مشمول
  • نسخ احتياطي تلقائي مجانييشمل خانة نسخ احتياطي واحدة يمكنك ضبطها للتشغيل يوميًا أو أسبوعيًا أو شهريًا.

Genoa VPS.G4

174.71 zł Save  22 %
135.88 zł شهري
  • 4 vCPUمعالج AMD EPYC مع أنوية vCPU مخصصة، على عتاد خوادم للمؤسسات. AMD EPYC G4
  • 8 GB DDR5ذاكرة
  • 100 GB NVMeتخزين
  • نطاق ترددي غير محدود
  • IPv4 & IPv6دعم IPv6 غير متاح حاليًا في فرنسا وفنلندا أو هولندا. مشمول
  • نسخ احتياطي تلقائي مجانييشمل خانة نسخ احتياطي واحدة يمكنك ضبطها للتشغيل يوميًا أو أسبوعيًا أو شهريًا.

Genoa VPS.G6

349.47 zł Save  22 %
271.80 zł شهري
  • 8 vCPUمعالج AMD EPYC مع أنوية vCPU مخصصة، على عتاد خوادم للمؤسسات. AMD EPYC G4
  • 16 GB DDR5ذاكرة
  • 200 GB NVMeتخزين
  • نطاق ترددي غير محدود
  • IPv4 & IPv6دعم IPv6 غير متاح حاليًا في فرنسا وفنلندا أو هولندا. مشمول
  • نسخ احتياطي تلقائي مجانييشمل خانة نسخ احتياطي واحدة يمكنك ضبطها للتشغيل يوميًا أو أسبوعيًا أو شهريًا.

Genoa VPS.G7

621.31 zł Save  22 %
485.39 zł شهري
  • 8 vCPUمعالج AMD EPYC مع أنوية vCPU مخصصة، على عتاد خوادم للمؤسسات. AMD EPYC G4
  • 32 GB DDR5ذاكرة
  • 250 GB NVMeتخزين
  • نطاق ترددي غير محدود
  • IPv4 & IPv6دعم IPv6 غير متاح حاليًا في فرنسا وفنلندا أو هولندا. مشمول
  • نسخ احتياطي تلقائي مجانييشمل خانة نسخ احتياطي واحدة يمكنك ضبطها للتشغيل يوميًا أو أسبوعيًا أو شهريًا.

AMD Ryzen VPS.R1

65.98 zł Save  18 %
54.33 zł شهري
  • 1 CPU مخصص معالج AMD Ryzen 9 7950X بسرعة 4.5 GHz أو ما يماثله، على معمارية Zen 4. vCPU
  • 4 GB DDR5ذاكرة
  • 50 GB NVMeتخزين
  • نطاق ترددي غير محدود
  • IPv4 & IPv6 مشمول دعم IPv6 غير متاح حاليًا في فرنسا وفنلندا أو هولندا.
  • نسخ احتياطي تلقائي مشمول

AMD Ryzen VPS.R2

116.46 zł Save  17 %
97.05 zł شهري
  • 2 CPU مخصصان معالج AMD Ryzen 9 7950X بسرعة 4.5 GHz أو ما يماثله، على معمارية Zen 4. vCPU
  • 8 GB DDR5ذاكرة
  • 100 GB NVMeتخزين
  • نطاق ترددي غير محدود
  • IPv4 & IPv6 مشمول دعم IPv6 غير متاح حاليًا في فرنسا وفنلندا أو هولندا.
  • نسخ احتياطي تلقائي مشمول

AMD Ryzen VPS.R4

427.14 zł Save  18 %
349.47 zł شهري
  • 8 CPU مخصصة معالج AMD Ryzen 9 7950X بسرعة 4.5 GHz أو ما يماثله، على معمارية Zen 4. vCPU
  • 32 GB DDR5ذاكرة
  • 400 GB NVMeتخزين
  • نطاق ترددي غير محدود
  • IPv4 & IPv6 مشمول دعم IPv6 غير متاح حاليًا في فرنسا وفنلندا أو هولندا.
  • نسخ احتياطي تلقائي مشمول

*VAT excluded.

INCLUDED WITH EVERY PLAN

✓ No setup fees ✓1 Gbps network
✓ Full root access on KVM ✓ Free server monitoring ✓ Firewall management ✓24/7 support

Batch a large UPDATE with @@ROWCOUNT

One SQL Server UPDATE against forty million rows is a single transaction. The transaction log has to hold every changed row until it commits. It can't truncate any of it while the statement runs, so a database in full recovery model grows the log by the size of the change plus overhead. It does not give that space back. On a VPS with a fixed disk that's how a maintenance job turns into an outage. Second problem: SQL Server escalates row locks to a table lock once a statement takes roughly 5,000 locks on one object. Every other session waits behind you for the duration.

Batching solves both. Small transactions commit, so the log can be truncated between them under a regular log backup schedule. A batch that stays under 5,000 locks never reaches the escalation threshold.

DECLARE @batch_size INT = 4000;

WHILE 1 = 1
BEGIN
    UPDATE TOP (@batch_size) dbo.orders
    SET order_status = 'archived'
    WHERE order_status = 'closed'
      AND placed_at < '20250101';

    IF @@ROWCOUNT < @batch_size
        BREAK;

    WAITFOR DELAY '00:00:00.200';
END

Two things matter here and a third is a courtesy. @@ROWCOUNT has to be read on the statement immediately after the UPDATE, since anything in between (including a SELECT or a PRINT) resets it. The predicate has to exclude rows the loop has already handled, which it does here because 'closed' becomes 'archived' and those rows leave the set on the next pass. Get that wrong and you have an infinite loop rewriting the same 4,000 rows until somebody notices. WAITFOR DELAY is the courtesy, and 200 milliseconds is a reasonable starting point on a busy server.

WAITFOR has a second form, WAITFOR TIME, which waits until a clock time you name. I used it once in a script that had to start at a fixed time on a box with no scheduler installed. It has nothing to do with batching.

Batch size is a tuning knob. Blocking that persists after batching usually means an index is missing on the predicate columns, a symptom that turns up in common SQL Server errors alongside the messages it produces. Four thousand is a sensible default because it stays under the escalation threshold. Ten thousand crosses the threshold and can escalate to a table lock, which is acceptable only when nothing else needs the table while the loop runs. Halve it if you are seeing long blocking chains.

MERGE versus separate INSERT, UPDATE and DELETE

MERGE gets a bad reputation online and some of it is out of date. Two things are true at once. MERGE is not deprecated, there's no deprecation notice on its page, and the historical correctness bugs that made people avoid it have largely been fixed in supported versions. Hugo Kornelis went through the old bug list and reached that conclusion, which is a useful counterweight to the older warnings.

And Microsoft's own MERGE documentation tells you to use something else for the common case: "When simply updating one table based on the rows of another table, improve the performance and scalability with INSERT, UPDATE, and DELETE statements." The same page adds "When heavy concurrency is expected, separate INSERT, UPDATE, and DELETE logic might perform better, with less blocking."

So the useful case is narrow. Reach for MERGE when you need matched, not-matched-by-target and not-matched-by-source handling in one pass over one source. For a plain upsert, write the UPDATE and the INSERT. Two rules are not optional if you do use it:

  • Terminate the statement with a semicolon. Without it you get error 10713, and the message is not helpful.
  • Add HOLDLOCK (or WITH (HOLDLOCK)) to the target. Microsoft states that "specifying the HOLDLOCK will prevent against unique key violations". Without it, two concurrent MERGEs can both decide a key doesn't exist and both insert it.

Ten minutes on Aaron Bertrand's caution article on MERGE before you put one into a nightly job will save you an evening later on. One more from the docs: never filter target rows in the ON clause to improve performance. That changes which rows count as matched, so what comes back is wrong, and speed stops mattering.

Test an UPDATE with BEGIN TRAN and ROLLBACK

For anything you're unsure of, wrap it:

BEGIN TRAN;

UPDATE dbo.orders
SET discount_pct = 10.00
WHERE customer_id = 91
  AND order_status = 'pending';

SELECT @@ROWCOUNT AS rows_affected;

SELECT order_id, discount_pct
FROM dbo.orders
WHERE customer_id = 91;

-- happy with it?
-- COMMIT;
ROLLBACK;

You see the row count and the resulting data with the change applied, then throw it away. This is the closest thing T-SQL has to a dry run, and unlike a SELECT preview it exercises the statement itself, triggers included.

The catch is that an open transaction holds its locks until you finish it. Leave that window open while you go and get a coffee and every reader of dbo.orders is waiting behind you. Run it, look at the output, close it. SELECT @@TRANCOUNT; tells you if you left one open, and SET XACT_ABORT ON; at the top of a batch means a mid-statement error rolls the whole thing back and never leaves you half done.

Add the OUTPUT INTO audit table to dbo.orders before you need it. The first time somebody asks who set four thousand orders to 'refunded', you'll have a row with a login name and a timestamp on it, and the conversation is short. Without it, it isn't.

Questions?

How do I undo an UPDATE I already committed?

You restore from backup, because T-SQL has no statement that reverses a committed UPDATE. With the database in the full recovery model and log backups running, restore the last full backup and the log backups under a new database name, and give each log restore the same STOPAT time, just before the bad statement ran. Then copy the correct values back into the live table with an UPDATE that joins the restored copy on the primary key. Under the simple recovery model there are no log backups to stop inside, so the closest you can get is the newest full backup plus the latest differential taken after it, if you take differentials.

Jörg L. Günther
About the author

Jörg L. Günther

Jörg has spent much of his career in enterprise software, working first with database deployments and customer environments, then in technical training and documentation. Since then he has written for companies working in virtualisation, database tooling and cloud infrastructure. The subjects he takes on often have a large operational component: database migrations, high-availability setups, storage configuration and procedures carried out on live systems. Jörg lives near Frankfurt. He plays piano and cycles for transport as much as for recreation. He's also been doing woodworking for several years - he says most of the furniture in his study is his own work.

Deploy your next project in seconds

Bring your winning ideas online faster, with modern hardware and unmetered bandwidth. Join a European cloud trusted by thousands of developers and businesses worldwide.