Triggers Need to Handle Multiple Records

On the StackOverflow podcast this week, I caught the guys talking about a SQL Server trigger question and I immediately panicked.  I think nine out of ten times when I see a trigger, it’s written incorrectly, and sure enough, this one had dangerously incorrect answers.

Triggers: You’re Doing It Wrong

When writing a trigger, it’s easy to think in terms of individual records being inserted, updated or deleted.  Developers and DBAs say to themselves, “I’m going to write a trigger to handle the record that just got inserted.”  They write triggers like this below one, which is supposed to only insert records if SomeNumericField is actually numeric:

CREATE TRIGGER mytrigger ON sometable
INSTEAD OF INSERT
AS BEGIN
  DECLARE @isnum TINYINT;
  SELECT @isnum = ISNUMERIC(SomeNumericField) FROM inserted;
  IF (@isnum = 1)
    INSERT INTO sometable SELECT * FROM inserted;
  ELSE
    RAISERROR('SomeNumericField must be numeric', 16, 1)
      WITH SETERROR;
END

Examine that row in bold.  What will happen in the trigger if we insert multiple records simultaneously, like if we run the following T-SQL statement?

INSERT INTO sometable SELECT * FROM someothertable

And what if some rows are valid, and some rows are not?  Will we be able to predict the ISNUMERIC(SomeNumericField) result?  Probably not.  We can rewrite this two ways: either letting the good rows in, or not letting any rows in at all.

Trigger that Rejects Only the Bad Rows

Let’s rewrite it so that we insert the good rows, but reject the bad ones.  I’m just using pseudocode here – you would want to clean this up for production:

CREATE TRIGGER mytrigger ON sometable
INSTEAD OF INSERT
AS BEGIN
  --First we insert the good rows
  INSERT INTO sometable SELECT * FROM inserted WHERE ISNUMERIC(SomeNumericField) = 1

  --Then we check to see if we had any bad rows
  IF EXISTS (SELECT TOP 1 * FROM inserted WHERE ISNUMERIC(SomeNumericField) = 0)
    RAISERROR('SomeNumericField must be numeric.  Some rows were not inserted.', 16, 1)
      WITH SETERROR;
END

So this trigger inserted our good rows where SomeNumericField is numeric, and then passed a message back to our code if any records were not inserted.  This is kinda dangerous, because our application won’t know which rows succeeded and which ones failed.  Ideally, we’d track which ones failed, so let’s add code to handle that:

CREATE TRIGGER mytrigger ON sometable
INSTEAD OF INSERT
AS BEGIN
  --First we insert the good rows
  INSERT INTO sometable SELECT * FROM inserted WHERE ISNUMERIC(SomeNumericField) = 1

  --Then we insert the bad rows into a tracking table
  INSERT INTO someRejectTable SELECT * FROM inserted WHERE ISNUMERIC(SomeNumericField) = 0

  --Then we check to see if we had any bad rows
  IF EXISTS (SELECT TOP 1 * FROM inserted WHERE ISNUMERIC(SomeNumericField) = 0)
    RAISERROR('SomeNumericField must be numeric.  Some rows were not inserted.', 16, 1)
      WITH SETERROR;
END

When you design someRejectTable, all of the fields should be nvarchars, because we can’t predict what kind of data we’re going to be inserting.  After all, the whole reason we’re dumping data into here is because SomeNumericField, which was supposed to be numeric, isn’t actually numeric.

This technique works well for data warehouses where we bring data in from text files and we can’t predict what will come in.  Sometimes people change input file formats, and what used to be a numeric field now has text in it – item numbers are a great example.

Trigger that Rejects Everything if One Row is Bad

If we need to know absolutely everything is okay before we insert our data, here’s how to do it (again, this is pseudocode):

CREATE TRIGGER mytrigger ON sometable
INSTEAD OF INSERT
AS BEGIN
  --Firsts we check to see if we had any bad rows
  IF EXISTS (SELECT TOP 1 * FROM inserted WHERE ISNUMERIC(SomeNumericField) = 0)
    RAISERROR('SomeNumericField must be numeric.  Nothing was inserted.', 16, 1)
      WITH SETERROR;
  ELSE
    --We insert everything, since we know it's all good
    INSERT INTO sometable SELECT * FROM inserted

END


In this code, we check all of the rows first, then if it succeeds, we insert the data.

More Reading on Trigger Development

If this stuff comes as a surprise to you, don’t panic: there’s a really good resource on getting started with T-SQL programming that covers this important issue and a lot of other gotchas.  Check out Beginning SQL Server 2005 Programming by Robert Vieira.  You can preview it in Google Books, and buy it from Amazon.

Brent Ozar

Brent specializes in performance tuning for SQL Server, VMware, and storage. He's one of the very few Microsoft Certified Masters of SQL Server, a published author, and a Microsoft MVP. He likes travel, Jeeps, Apple gear, jokes, and writing about himself in the third person. Read more and contact Brent.

Website - Twitter - Facebook - More Posts

12 Responses to Triggers Need to Handle Multiple Records
  1. Jon Homan
    January 22, 2009 | 3:38 PM

    RE: inserting bad rows into reject table

    Didn't you mean:
    –Then we insert the bad rows into a tracking table
    INSERT INTO someRejectTable SELECT * FROM inserted WHERE ISNUMERIC(SomeNumericField) = 0 [not 1]

    Otherwise it seems the two inserts seems the same except for the table.

    • Brent Ozar
      January 22, 2009 | 3:41 PM

      You are absolutely right, my good man! Great catch. Fixed that.

  2. Mary Lee
    January 22, 2009 | 4:46 PM

    Would changing the following optimize the query?

    –Then we check to see if we had any bad rows
    IF EXISTS (SELECT TOP 1 * FROM inserted WHERE ISNUMERIC(SomeNumericField) = 0)
    BEGIN
    –Then we insert the bad rows into a tracking table
    INSERT INTO someRejectTable SELECT * FROM inserted

    RAISERROR('SomeNumericField must be numeric. Some rows were not inserted.', 16, 1)
    WITH SETERROR;
    END

    • Brent Ozar
      January 22, 2009 | 5:50 PM

      You're inserting all records from "inserted" into the someRejectTable if ANY of them have IsNumeric = 0, but that would be a problem since some of the records might be okay. It depends on how you want to handle rejects. If you want to reject ALL records if ANY of them are bad, then your method will work. If you want to take the good records but reject the bad ones, then stick with my method. Good idea though!

      • Mary Lee
        January 22, 2009 | 6:25 PM

        Duh? You're right. What was I thinking?

        • Brent Ozar
          January 22, 2009 | 8:11 PM

          Hey, don't feel bad – I'm the one who wrote the blog post and had broken business logic in the first place! Thank goodness Jon Homan caught it.

  3. Mary Lee
    January 22, 2009 | 4:49 PM

    Great tips btw. Thanks!

  4. J.D. Mullin
    January 22, 2009 | 7:33 PM

    I don't use SQL Server, but doesn't is support row-level triggers, where the trigger truly is called once for each row, even when a batch of rows are inserted/modified? There are obviously efficiency issues with row-leve, but it seems like an alternative solution.

    • Brent Ozar
      January 22, 2009 | 8:10 PM

      Right, there's efficiency problems – and being a DBA, I want to find the most efficient solution when I'm dealing with a performance-killing feature like triggers. Good idea though!

  5. Deeps
    July 16, 2010 | 4:48 AM

    Please help me, I am facing a problem with instead of insert trigger where if I insert into table a, it should insert only not exixting rows, if there exists same row it should update few columns. but its working for multiple row insert like
    insert into…select * from table b

    here is my trigger code

    ALTER TRIGGER DEMO
    ON SAMPLE_DEMO
    INSTEAD OF INSERT
    AS
    BEGIN
    IF EXISTS(SELECT * FROM INSERTED I INNER JOIN SAMPLE_DEMO D ON I.INV_NUM=D.INV_NUM WHERE I.FSC_NAMED.FSC_NAME)
    BEGIN
    UPDATE SAMPLE_DEMO SET FSC_NAME=I.FSC_NAME, BUCKET=’NULL’ FROM INSERTED I,SAMPLE_DEMO D WHERE I.INV_NUM=D.INV_NUM
    END
    ELSE
    BEGIN
    UPDATE SAMPLE_DEMO SET BUCKET=’GLOBAL’ FROM INSERTED I,SAMPLE_DEMO D WHERE I.INV_NUM=D.INV_NUM
    END
    IF EXISTS(SELECT * FROM INSERTED WHERE INV_NUM NOT IN(SELECT INV_NUM FROM SAMPLE_DEMO))
    BEGIN
    SELECT * INTO TEMP FROM INSERTED WHERE INV_NUM NOT IN(SELECT INV_NUM FROM SAMPLE_DEMO)
    INSERT INTO SAMPLE_DEMO SELECT * FROM TEMP
    END
    DROP TABLE TEMP
    END

    • Brent Ozar
      July 16, 2010 | 4:51 AM

      Deeps – rather than help you here, it’s better for you if you post this question on http://stackoverflow.com. That way you can get help from more programmers and SQL Server people at once in a collaborative environment.

  6. mohit pandey
    January 25, 2011 | 11:18 AM

    i find good answers here…….. nice work and website…
    thanks…….

Leave a Reply

Wanting to leave an <em>phasis on your comment?

Notify me of followup comments via e-mail. You can also subscribe without commenting.