INSERT, UPDATE, DELETE & the OUTPUT Clause
T-SQL's DML goes beyond ANSI standard with two big additions: the UPDATE ... FROM / DELETE ... FROM extensions for set-based updates from joins, and the OUTPUT clause for capturing affected rows.
INSERT
-- Single row, all columns in declared order
INSERT INTO departments VALUES (270, 'Payroll', 200, 1700);
-- Single row, named columns (preferred — survives schema changes)
INSERT INTO departments (department_id, department_name, location_id)
VALUES (280, 'Procurement', 1700);
-- Multiple rows in one statement
INSERT INTO departments (department_id, department_name, location_id) VALUES
(290, 'Risk', 1700),
(300, 'Compliance', 1700),
(310, 'Audit', 1700);
-- INSERT ... SELECT — copy from another query
INSERT INTO employees_archive (employee_id, first_name, last_name, salary, archived_at)
SELECT employee_id, first_name, last_name, salary, GETDATE()
FROM employees
WHERE hire_date < '2010-01-01';
-- INSERT ... EXEC — capture stored proc output as rows
INSERT INTO #plan_log (plan_handle, sql_text)
EXEC sp_get_query_stats @database_id = 7;
Always list the target columns. Anonymous
INSERT INTO t VALUES (...) breaks the moment someone adds a column to the table.
UPDATE
-- Standard ANSI update
UPDATE employees
SET salary = salary * 1.05
WHERE department_id = 50;
-- T-SQL UPDATE ... FROM — update from a join
UPDATE e
SET e.salary = e.salary * b.bonus_pct
FROM employees e
JOIN department_bonus b ON b.department_id = e.department_id
WHERE b.year = 2024;
-- Multiple columns
UPDATE employees
SET salary = 9000,
commission_pct = 0.10,
updated_at = SYSDATETIME()
WHERE employee_id = 100;
-- Conditional update with CASE
UPDATE employees
SET salary =
CASE
WHEN years_of_service >= 10 THEN salary * 1.10
WHEN years_of_service >= 5 THEN salary * 1.05
ELSE salary * 1.02
END
WHERE department_id = 80;
DELETE
-- Simple delete
DELETE FROM orders WHERE order_date < '2010-01-01';
-- T-SQL DELETE ... FROM — delete based on a join
DELETE o
FROM orders o
JOIN customers c ON c.customer_id = o.customer_id
WHERE c.region = 'Inactive';
-- Delete all rows (logged), or use TRUNCATE for minimally-logged reset
DELETE FROM #scratch;
TRUNCATE TABLE #scratch; -- faster, resets identity, requires no FK references
Both
UPDATE ... FROM and DELETE ... FROM are non-deterministic if the join produces multiple matches per target row — only one of the matches "wins". Always check your join keys are unique on the source side.
The OUTPUT Clause
OUTPUT returns rows affected by the DML and references two virtual tables:
| Pseudo-table | INSERT | UPDATE | DELETE |
|---|---|---|---|
inserted |
New row | Row after update | – |
deleted |
– | Row before update | Row being deleted |
OUTPUT to the Caller
-- Capture inserted rows back to the caller (acts like a SELECT)
INSERT INTO departments (department_id, department_name, location_id)
OUTPUT inserted.department_id, inserted.department_name
VALUES (320, 'Innovation', 1700);
-- Update with before/after view
UPDATE employees
SET salary = salary * 1.10
OUTPUT deleted.employee_id,
deleted.salary AS old_salary,
inserted.salary AS new_salary
WHERE department_id = 60;
-- DELETE returning what was removed
DELETE FROM orders
OUTPUT deleted.*
WHERE order_date < '2015-01-01';
OUTPUT INTO a Table or Variable
Use OUTPUT ... INTO to persist rows for downstream processing — typically an audit table:
DECLARE @audit TABLE (
employee_id INT,
old_salary DECIMAL(10,2),
new_salary DECIMAL(10,2),
changed_at DATETIME2
);
UPDATE employees
SET salary = salary * 1.10
OUTPUT deleted.employee_id,
deleted.salary,
inserted.salary,
SYSDATETIME()
INTO @audit
WHERE department_id = 60;
SELECT * FROM @audit;
Audit Pattern
CREATE TABLE dbo.salary_audit (
audit_id BIGINT IDENTITY(1,1) PRIMARY KEY,
employee_id INT,
old_salary DECIMAL(10,2),
new_salary DECIMAL(10,2),
changed_by SYSNAME DEFAULT SUSER_SNAME(),
changed_at DATETIME2 DEFAULT SYSDATETIME()
);
UPDATE employees
SET salary = salary + 500
OUTPUT deleted.employee_id, deleted.salary, inserted.salary
INTO dbo.salary_audit (employee_id, old_salary, new_salary)
WHERE employee_id = 100;
Returning the Identity Just Inserted
Combine OUTPUT with INSERT to get the new identity value reliably (no @@IDENTITY race conditions):
DECLARE @new_ids TABLE (id INT);
INSERT INTO products (sku, name, list_price)
OUTPUT inserted.product_id INTO @new_ids
VALUES ('ABC-100', 'Widget', 9.99),
('ABC-101', 'Gadget', 14.99);
SELECT id FROM @new_ids;
Best Practices
- Always list target columns in
INSERT. - Watch out for non-deterministic
UPDATE ... FROMwhen the join is many-to-one in the wrong direction. - Prefer
OUTPUT INTOaudit tables over triggers when the audit lives next to the originating statement — it's faster and easier to reason about. - Use
TRUNCATE TABLEfor full-reset performance, but remember it cannot fire DML triggers and requires no incoming FK references.
Summary
INSERT ... VALUES,INSERT ... SELECT, andINSERT ... EXECall populate tables.- T-SQL's
UPDATE ... FROMandDELETE ... FROMenable join-based set updates. OUTPUTexposesinsertedanddeletedvirtual tables for the affected rows.OUTPUT ... INTOis the canonical way to capture identity values and build audit pipelines without triggers.