SQL Server: Use INSERT INTO SELECT Instead of a Cursor

INSERT INTO SELECT can copy a set of rows without fetching and inserting them one at a time. I learned that lesson during a code review early in my career. The working code was only the beginning of the conversation.

Gouache painting of a stack of blank cards and an open document tray, representing a set of rows.

The Code Review I Still Remember

I was working as a network engineer in the United States. Our server inventory lived in a SQL Server table, and I needed to copy its rows into another table. I wrote a loop that inserted one row at a time.

Later, my team leader showed me a much shorter INSERT INTO SELECT statement. I shared the lesson at a team meeting and thanked my team leader for the guidance. That review helped me become more comfortable learning from my mistakes.

One correction to my original example: the code I published used a counting loop, not a declared cursor. A cursor is another way to process rows individually. Both are useful to compare with a set-based insert.

Build a Small Example

Run these blocks in order in the same query window. They use temporary tables, so no permanent tables are created. The source has four servers, and only three are active.

SET NOCOUNT ON;
CREATE TABLE #SourceServers
(
    ServerID int NOT NULL PRIMARY KEY,
    ServerName varchar(100) NOT NULL,
    IsActive bit NOT NULL
);
INSERT #SourceServers (ServerID, ServerName, IsActive)
VALUES (1, 'Alpha', 1), (3, 'Bravo', 1),
       (10, 'Charlie', 1), (20, 'Delta', 0);
CREATE TABLE #CursorCopy (ServerID int PRIMARY KEY, ServerName varchar(100));
CREATE TABLE #SetCopy (ServerID int PRIMARY KEY, ServerName varchar(100));
CREATE TABLE #LoopCopy (ServerID int PRIMARY KEY, ServerName varchar(100));

Copy Rows With a Cursor

This cursor fetches each active server and runs an INSERT for that row. It is intentionally simple: the only work inside the loop is the insert.

DECLARE @ServerID int, @ServerName varchar(100);
DECLARE ServerCursor CURSOR LOCAL FAST_FORWARD FOR
    SELECT ServerID, ServerName FROM #SourceServers WHERE IsActive = 1;
OPEN ServerCursor;
FETCH NEXT FROM ServerCursor INTO @ServerID, @ServerName;
WHILE @@FETCH_STATUS = 0
BEGIN
    INSERT #CursorCopy (ServerID, ServerName)
    VALUES (@ServerID, @ServerName);
    FETCH NEXT FROM ServerCursor INTO @ServerID, @ServerName;
END;
CLOSE ServerCursor;
DEALLOCATE ServerCursor;

Copy the Same Rows as a Set

Here is the same copy in one statement. The column lists make the source and destination mapping explicit.

INSERT #SetCopy (ServerID, ServerName)
SELECT ServerID, ServerName
FROM #SourceServers
WHERE IsActive = 1;
Gouache painting of blank cards grouped together in a sorting tray, illustrating a set of rows.

Check both destinations rather than assuming they match. Each destination has a primary key, so the two EXCEPT checks are sufficient for this example.

SELECT 'Cursor' AS Method, ServerID, ServerName FROM #CursorCopy
UNION ALL
SELECT 'Set' AS Method, ServerID, ServerName FROM #SetCopy
ORDER BY Method, ServerID;
SELECT (SELECT COUNT(*) FROM #CursorCopy) AS CursorRows,
       (SELECT COUNT(*) FROM #SetCopy) AS SetRows,
       (SELECT COUNT(*) FROM
           (SELECT ServerID, ServerName FROM #CursorCopy
            EXCEPT SELECT ServerID, ServerName FROM #SetCopy) AS a) AS CursorOnly,
       (SELECT COUNT(*) FROM
           (SELECT ServerID, ServerName FROM #SetCopy
            EXCEPT SELECT ServerID, ServerName FROM #CursorCopy) AS b) AS SetOnly;

On my test, each method copied server IDs 1, 3 and 10. Both counts were 3, and CursorOnly and SetOnly were 0. The ORDER BY makes the displayed rows easy to compare; it does not promise insertion order.

The Hidden Problem in My Original Loop

My old example counted rows and then treated that count as an ID. That only works when IDs happen to match every number in the range. Real IDs can have gaps, as this small test shows.

DECLARE @Remaining int = (SELECT COUNT(*) FROM #SourceServers WHERE IsActive = 1);
WHILE @Remaining > 0
BEGIN
    INSERT #LoopCopy (ServerID, ServerName)
    SELECT ServerID, ServerName FROM #SourceServers
    WHERE IsActive = 1 AND ServerID = @Remaining;
    SET @Remaining -= 1;
END;
SELECT ServerID, ServerName FROM #LoopCopy ORDER BY ServerID;

There are three active rows, so the loop checks IDs 3, 2 and 1. It copies IDs 1 and 3 but misses ID 10. INSERT INTO SELECT uses the qualifying rows themselves and does not depend on consecutive IDs.

Before Replacing a Cursor

Check what the loop does besides inserting. A cursor may call a procedure, depend on order, or handle an error separately for each row. A single INSERT has a different statement boundary, which can affect triggers and error handling.

Review constraints, defaults, duplicate handling, and transaction requirements before changing production code. For large copies, test the plan, transaction log use, locking, and batch size on representative data. Set-based code is a strong starting point, not a promise that every workload behaves the same way.

If you add a NOT EXISTS filter for retries, consider concurrent sessions too. A unique constraint and an appropriate retry design help protect the rule. A filter alone does not guarantee that two sessions cannot insert the same key.

Clean Up

When you are finished, remove the temporary tables from this session.

DROP TABLE #LoopCopy, #SetCopy, #CursorCopy, #SourceServers;

The code review stayed with me because it was kind and practical. When the work is the same for every row, I now look for a way to express it as a set.

A shorter insert is useful, but a correct and understood insert is better.

Published by Pinal Dave on SQLAuthority. More of my work at pinaldave.com.


Discover more from SQL Authority with Pinal Dave

Subscribe to get the latest posts sent to your email.

Best Practices, SQL Scripts
Previous Post
LIKE Character Ranges: [a-f], [^0-9] and Collation Traps
Next Post
SQL SERVER – Tips from the SQL Joes 2 Pros Development Series – Aggregates with the Over Clause – Day 10 of 35

Related Posts

37 Comments. Leave new

  • I believe that avoiding ready-made foods would
    be the first step so that you can lose weight. They will often taste beneficial, but
    prepared foods include very little nutritional value, making you eat more just to have
    enough strength to get through the day. In case
    you are constantly taking in these foods, transferring to whole grains and other complex carbohydrates will aid you
    to have more energy while eating less. Thanks alot : ) for your
    blog post.

    Reply
  • Its like you learn my thoughts! You seem to grasp a lot about this,
    like you wrote the book in it or something. I think that
    you could do with a few p.c. to force the message house a little bit, but other
    than that, this is wonderful blog. A great read.

    I will definitely be back.

    Reply
  • hello!,I really like your writing so much! share we keep in touch more about your post on AOL?
    I require an expert in this space to resolve my problem.
    Maybe that’s you! Taking a look ahead to see you.

    Reply
  • thank you for your small but important lessons

    Reply
  • Thanks a lot for sharing this with all of us you really know what you are talking about!
    Bookmarked. Please also visit my website =).
    We could have a link exchange arrangement between us!

    Reply
  • SELECT .. INTO and INSERT INTO … SELECT * are both bulk logged operations and can benefit from it if the recovery model is SIMPLE or BULK LOGGED

    BTW: Pinal, great blog.
    I’ve learned vey much from it within the last 5 years :)

    Reply
  • Though these are fast but create table lock. So in highly concurrent scenarios these does not scale. In scenario where high concurrent insert with bigger row set create problem if use build insert. In these scenarios cursor bases operation is better. So build operation is not always good. Any inputs?

    Reply
  • Hi Pinal,
    Need your guidance in solving below query
    -1. Current process has ONE MS Access Database table with all fields of information in it. Lets say STUDENTS ( has all info about students bunched togather)
    -2. Designed a new SQL server 2008 DB with all info grouped togather in resp tables- so there are more than one table now – Student, Personal, Courses, Teachers, Grades, Research
    -3. Need to migrate data from ONE old table and distribute it over into these multiple tables with ‘Student_id’ as primary key of Students table, and ‘Student_id’ – ref as Foreign Key in all other tables
    – But some columns from records of old table has data which needs to be checked for before inserting into new tables ( some data has been added to Static data – Such as Course list, so sourse name has to be one othe entires from COURSE_NAME table, while old data has some free text data inti so insert would fial on FK contraint)
    What is the best way to achieve this data migration into new table stucture?
    Thanks for (any) pointers

    Reply
  • Say i am inserting 50 rows using insert… select , if any error occurs will it roll back automatically?

    Reply
  • Hi Pinal, first I would like to thank you for such an excellent site. I have learned many things from you concerning SQL server. Right now I am honestly getting confused by cursor performance.

    Everywhere I look I see that Cursors are bad… I am trying to reconcile these statements with my testing results against a foreach loop in SSIS 2012. Any clarity or if I am doing something incorrectly in SSIS would be appreciated.

    This was my test cursor with 1000 records.

    /************ Test Cursor Performance ********************/
    DELETE testImportDatabaseMaintenance

    DECLARE @DBMRecordid AS INT , @Endpointid AS VARCHAR(50)

    DECLARE test_cur CURSOR FAST_FORWARD READ_ONLY FOR

    SELECT idm.DBMRecId ,idm.EndpointId
    from ImportDatabaseMaintenance idm

    OPEN test_cur

    FETCH NEXT FROM test_cur INTO @DBMRecordid , @Endpointid

    WHILE @@FETCH_STATUS = 0
    BEGIN

    INSERT Testimportdatabasemaintenance
    (testImportDatabaseMaintenance.DBMRecId , testImportDatabaseMaintenance.EndpointId )

    VALUES (@DBMRecordid , @Endpointid )

    FETCH NEXT FROM test_cur INTO @DBMRecordid , @Endpointid

    END

    CLOSE test_cur
    DEALLOCATE test_cur

    /*****************************************************************************************/
    this cursor completed in less than 1 second from SSMS.

    I used this sp with a foreach loop in SSIS.

    ALTER PROCEDURE A_Test_DBM_SSIS_3 @DBMRecId AS INT ,
    @EndPointId as varchar(50)
    AS

    INSERT testImportDatabaseMaintenance
    (
    DBMRecId,
    EndpointId
    )

    VALUES ( @DBMRecId , @Endpointid)
    GO

    The package does the same functionality as the cursor.
    in a container I load the variable objIdbm with the recordset and then execute a foreach ADO Enumerator against the Stored Procedure referenced above. time to complete 388 seconds.

    I started to search the web and found many other blogs and articles that are basically having the same results.

    Am I missing something?

    Thank you in advance for your time.

    Reply
  • thanks for sharing, it is a good article

    Reply

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.