Skip to main content
SQL Server · lesson 6 of 7
SQL Server intermediate Lesson 6 of 7

The Trigger That Silently Dropped a Row

A trigger reading `inserted` into scalar variables works fine for a single-row insert -- and silently audits only one of two rows on a multi-row insert, because a trigger fires once per statement, not once per row.

By SkillByExample · Published

sqlserver tsql stored-procedures triggers sqlcmd

A trigger fires once per statement, not once per row — and code that forgets this passes every test that only inserts one row at a time.

A stored procedure with a parameter

CREATE OR ALTER PROCEDURE GetBooksByAuthor
    @AuthorId INT
AS
BEGIN
    SELECT TOP 5 book_id, CAST(title AS VARCHAR(20)) AS title, price
    FROM books WHERE author_id = @AuthorId ORDER BY book_id;
END
GO
EXEC GetBooksByAuthor @AuthorId = 1;
book_id     title                price
----------- -------------------- --------
          1 Book Title 1            11.99
         51 Book Title 51           21.99
        101 Book Title 101          31.99
        151 Book Title 151          41.99
        201 Book Title 201          11.99

(5 rows affected)

GetBooksByAuthor is a named, reusable, parameterized query — the same benefit a CTE gives within one statement, extended across every statement and application that calls it.

The bug: a trigger that only sees one row

CREATE TABLE price_audit (book_id INT, new_price DECIMAL(6,2));

CREATE OR ALTER TRIGGER trg_book_price_audit
ON books
AFTER INSERT
AS
BEGIN
    DECLARE @id INT, @price DECIMAL(6,2);
    SELECT @id = book_id, @price = price FROM inserted;
    INSERT INTO price_audit (book_id, new_price) VALUES (@id, @price);
END
GO

INSERT INTO books (title, author_id, price, published_year)
VALUES ('New Book A', 1, 15.00, 2026), ('New Book B', 2, 25.00, 2026);

SELECT * FROM price_audit;
(2 rows affected)

book_id     new_price
----------- ---------
       5001     15.00

(1 rows affected)

Two books were inserted (2 rows affected) — only one made it into price_audit. SELECT @id = book_id, @price = price FROM inserted assigns a multi-row result into scalar variables, and SQL Server silently keeps just one row’s values, discarding the rest with no error. New Book B was never audited, and nothing about running this code told you that.

The fix: process inserted as a set

CREATE OR ALTER TRIGGER trg_book_price_audit
ON books
AFTER INSERT
AS
BEGIN
    INSERT INTO price_audit (book_id, new_price)
    SELECT book_id, price FROM inserted;
END
GO

INSERT INTO books (title, author_id, price, published_year)
VALUES ('New Book C', 1, 35.00, 2026), ('New Book D', 2, 45.00, 2026);

SELECT * FROM price_audit;
(2 rows affected)

book_id     new_price
----------- ---------
       5004     45.00
       5003     35.00

(2 rows affected)

Both new books are audited this time. INSERT ... SELECT ... FROM inserted is set-based — it processes every row inserted actually contains, not just one, which is the correct default assumption for any trigger logic: never assume a statement only affects one row.

Practice

1. Get a scalar value back from a procedure using an OUTPUT parameter.
CREATE OR ALTER PROCEDURE CountBooksByAuthor
    @AuthorId INT,
    @BookCount INT OUTPUT
AS
BEGIN
    SELECT @BookCount = COUNT(*) FROM books WHERE author_id = @AuthorId;
END
GO

DECLARE @cnt INT;
EXEC CountBooksByAuthor @AuthorId = 1, @BookCount = @cnt OUTPUT;
SELECT @cnt AS book_count;
book_count
-----------
        102

102 — the original 100 books for author 1, plus the two new ones inserted while testing the trigger above. OUTPUT parameters are the correct use of a scalar assignment: COUNT(*) always returns exactly one row, so there’s no multi-row ambiguity the way the buggy trigger had.

2. Confirm the trigger actually exists using database metadata, not application code.
SELECT CAST(name AS VARCHAR(30)) AS trigger_name
FROM sys.triggers WHERE parent_id = OBJECT_ID('books');
trigger_name
------------------------------
trg_book_price_audit

(1 rows affected)

sys.triggers lists what’s actually attached to the table right now, straight from SQL Server’s own metadata — the reliable way to check “does something fire on writes here,” since a trigger could have been added by anyone, at any time, without it being obvious from application code alone.

3. Confirm the buggy trigger's data loss is really about row count, not the second book specifically.

The buggy version audited New Book A (the first of the two rows), not New Book B — so the loss isn’t “the second row is special,” it’s “only one row survives a scalar assignment, whichever one SQL Server happens to process last.” A three-row INSERT would still only produce one audit row with the buggy trigger, and it would be equally wrong to assume which one. The fix (set-based INSERT ... SELECT) is correct regardless of how many rows a statement affects — 1, 2, or 200.

Next: backup and restore — a real database backup, and recovering from a dropped table.

Frequently Asked Questions

How many times does an AFTER INSERT trigger fire for a multi-row INSERT?
Once — for the whole statement, not once per row. The inserted virtual table inside the trigger contains all the rows the statement actually inserted, which is exactly why code that reads inserted into a single scalar variable only ever sees one of them.
Why did SELECT @id = book_id, @price = price FROM inserted only capture one row?
Assigning a SELECT's result to scalar variables takes exactly one row's values — when the SELECT would otherwise return multiple rows, SQL Server picks one silently (typically the last one processed) and discards the rest, with no error or warning that other rows existed at all. It's a genuinely dangerous pattern because it works perfectly for the single-row case most developers test first.
What's the actual fix for reading multi-row trigger data correctly?
Use a set-based INSERT ... SELECT (or UPDATE ... FROM, or MERGE) against the inserted (and deleted, for updates/deletes) virtual tables directly, instead of assigning their columns into scalar variables. That processes every row the statement affected, not just one.
What does an OUTPUT parameter on a stored procedure actually do?
It lets a procedure return a scalar value back into a variable in the calling code, in addition to (or instead of) a result set — CountBooksByAuthor's @BookCount OUTPUT parameter is read by the caller with EXEC ... @BookCount = @cnt OUTPUT, then used directly in the next statement, without needing to SELECT a result set just to get one number.
How do I check what triggers exist on a table without reading application code?
sys.triggers, filtered by parent_id = OBJECT_ID('tablename'), lists every trigger defined on that table directly from the database's own metadata — the reliable way to answer 'does something fire on writes to this table' when the trigger might have been added by someone else, or forgotten about entirely.