Adventures in the Modern Monolith: How do you keep a secret at Tilt?
When 50+ Azure App Service instances restart simultaneously and all reach for the same Key Vault, things break.
Running out of range on a primary key in SQL Server is a ticking time bomb, especially for high-traffic production tables. This article dives deep into the process of migrating a primary key column from INT to BIGINT, tackling challenges like constraints, clustered indexes, and data synchronization. Learn the tested strategies, performance tweaks, and lessons from a real-world scenario involving 650 million rows—ensuring scalability and reliability for your database. Perfect for engineers navigating similar database dilemmas.
My initial thoughts when presented with the above problem was, “Oh, no big deal, we’ll just update it, right? WRONG!
In SQL Server, the INT data type ranges from -2,147,483,648 to 2,147,483,647. When a primary key (PK) column with the IDENTITY(1,1) property approaches this upper limit, it poses a significant risk to database operations. This article details the challenges and solutions encountered during the migration of a PK column from INT to BIGINT in a high-traffic production environment.
The primary key uniquely identifies each row in a table. An IDENTITY(1,1) property ensures that each new row receives a sequential integer value starting from 1. However, SQL Server does not reuse identity values from deleted rows, leading to potential exhaustion of the INT range. In our case, the PK value had reached approximately 1.9 billion, with the table containing around 650 million rows.
Well, nothing good! Every single new insert will start to fail as the data type in the PK column can’t physically hold a number higher than the top of the range!
Here’s what our table looked like to begin with:
CREATE TABLE [dbo].[LargeTable](
[LargeTableId] [int] IDENTITY(1,1) NOT NULL,
[ErrorMessage] [nvarchar](max) NULL,
[DateCreated] [datetime]
CONSTRAINT [PK_dbo.LargeTable] PRIMARY KEY CLUSTERED
(
[LargeTableId] ASC
) WITH (STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
) ON [PRIMARY]
“Can we just ALTER TABLE LargeTable ALTER COLUMN LargeTableId BIGINT?”
Ok, let’s test this out in a local database and see what happens:

Well, that didn’t work, but ok, let’s work through the error. This approach failed due to the existing PK constraint, which prevented the alteration of the column’s data type.
What about row compression?
I came across a blog post that mentioned the ability to update a column’s data type from INT to BIGINT by utilizing row compression. Ultimately, the same issue with the PK constraint stopped this approach from working but remains a great option for non-constrained columns.
We can drop the constraint and then run the ALTER command.

That worked! Let’s confirm this will work though in a real production situation. If possible, the best way to do something like this is request a copy of the database (specifically, just the actual table and any related tables) be made temporarily somewhere you can test this out.
Now that we have a copy of what the table looks like in production, let’s attempt the above command.
When this was attempted, not only did the command never finish, but it was drawing down a ton of server resources while it was trying to apply these changes!
The reason this was occurring has to do with how data is structured in SQL server. If you look at the PK definition, you’ll notice that it’s CLUSTERED . This means that the way that data is stored in this table is dictated by the PK value. Unfortunately for us, this also means that if we were to drop the PK, the index would go away and the table would have to reorganize itself as a HEAP instead of the B-Tree structure that it was w/ the Clustered PK.
We’re now in a situation where we can’t realistically get rid of the PK or alter it in any way! Sounded like the only option was to migrate to a new table and backfill all the data over (remember this table is highly used and contains well over 650 million rows!)
Create a copy of the table but with the PK being a BIGINT and come up with a syncing mechanism
This approach is generally what other DBA experts recommend when dealing with a production table that needs this treatment but what wasn’t clear from further research was a consistent implementation of approach.
A few approaches that were considered:
TRIGGER to keep new rows added or updated in the current table in sync with the new one.Ultimately, Option 3 was chosen as the most straightforward, least risky, and lowest amount of required downtime with no impact to application code necessary.
Some additional considerations were made once this approach was chosen:
nvarchar(max) , a frequent source of performance pain because any time it exceeded 4000 characters in length, SQL will store that information “off page”, increasing query time to retrieve it from discSince a new table was being created, we decided to truncate this column down to the maximum size before being stored off page, nvarchar(4000)
Again, this was tested against a copy of the table in production on a separate server and once a working script was written, the test went smoothly. It took a few hours to insert everything from the old table to the new, but things were looking good!
The backfill sync script looked like this:
DECLARE
@CurrentId INT,
@MaxId BIGINT,
@message VARCHAR(1000),
@batchSize INT,
@EndTime AS DATETIME,
@StartTime AS DATETIME,
@totalInserted INT,
@totalIterations INT
-- Step 3: Turn on identity insert for new table
SET IDENTITY_INSERT dbo.LargeTable_NEW ON
-- Step 4: Set current transaction
SET @CurrentId = 0; -- Update this as necessary
SET @batchSize = 10000;
SELECT @StartTime = GETUTCDATE()
SELECT @EndTime = @StartTime
-- Step 5: Get max and min ID from current table
SELECT TOP 1 @MaxId = a.LargeTableId FROM LargeTable AS iac WITH (NOLOCK) ORDER BY a.LargeTableId DESC
SELECT TOP 1 @CurrentId = COALESCE(LargeTableId, 0) FROM LargeTable_NEW AS new WITH (NOLOCK) ORDER BY new.LargeTableId DESC
SET @totalInserted = 0
SET @totalIterations = 0
-- Step 6: Start loop while the current ID is less than the max id
WHILE @CurrentId < @MaxId
BEGIN
SET @message =
'Processing from [' + CAST(@CurrentId AS VARCHAR(100)) + ']'
+ ' to [' + CAST(@CurrentId + @batchSize AS VARCHAR(100)) + '] '
+ CAST(ROUND(((CAST(@CurrentId AS FLOAT)/CAST(@MaxId AS FLOAT))), 4)*100 AS VARCHAR(100)) + '%%'
RAISERROR (@message, 0, 1) WITH NOWAIT;
SET NOCOUNT ON;
-- Start timestamp
SET @StartTime = GETUTCDATE()
INSERT INTO LargeTable_NEW
(
LargeTableId,
ErrorMessage,
DateCreated
)
SELECT
f.LargeTableId,
f.ErrorMessage,
f.DateCreated
FROM
(
-- NOTE: This batch size is hardcoded here because SQL server was creating horrible execution plans with this value
-- being represented as a variable, even though it was constant. This change sped up the execution time by 80% towards
-- the back half of the migration
SELECT TOP 200000
old.LargeTableId,
CONVERT(NVARCHAR(4000), old.ErrorMessage) AS ErrorMessage,
old.DateCreated
FROM
LargeTable AS old WITH (NOLOCK)
LEFT JOIN LargeTable_NEW AS new ON new.LargeTableId = old.LargeTableId
WHERE
old.LargeTableId > @CurrentId
AND new.LargeTableId IS NULL
ORDER BY
old.LargeTableId
) AS f
OPTION (MAXDOP 2);
SET @totalInserted = @totalInserted + @@ROWCOUNT
SELECT TOP 1
@CurrentId = LargeTableId
FROM
LargeTable_NEW
ORDER BY
LargeTableId DESC
SET @totalIterations = @totalIterations + 1
-- End timestamp
SET @EndTime = GETUTCDATE()
-- Log elapsed
SET @message = 'Elapsed: [' + CONVERT(VARCHAR, CAST(@EndTime - @StartTime AS TIME), 114) + ']'
+ ' Inserted So Far: [' + CAST(@totalInserted AS VARCHAR) + ']'
RAISERROR (@message, 0, 1) WITH NOWAIT;
END
SET @message = 'Total Iterations = [' + CAST(@totalIterations AS VARCHAR) + ']'
+ ' Total Records = [' + CAST(@totalInserted AS VARCHAR) + ']'
RAISERROR (@message, 0, 1) WITH NOWAIT;
-- Step 7: Set IDENTITY_INSERT to OFF for the new table
SET IDENTITY_INSERT [dbo].[LargeTable_NEW] OFF;
We proceeded to create the new table in production and start running the script to backfill the data. With things looking good, a cutover plan was put in place where we planned our downtime window.
At this point a few things happened in quick succession over the next week or so:
sp_rename on all the appropriate objectsMigrating a primary key column from INT to BIGINT is far from a straightforward task, especially in production environments with large, high-traffic tables. Here are the critical lessons we learned through this process:
nvarchar(max) to nvarchar(4000), reducing storage requirements and improving query performance.With over 15 years of experience in software engineering, I have worked on various web development, cloud services, and e-commerce projects for leading companies in the travel, home services, and automotive industries.
As a Senior Software Engineer at Empower, I work on cross-cutting concerns on the Platform Team, enabling other developers throughout the organization.

I am proficient in C#, .NET, and other web technologies, and I enjoy working with agile teams and applying best practices to deliver high-quality and user-friendly solutions. I am passionate about creating innovative and impactful products that enhance the lives of people and solve real-world problems.
When 50+ Azure App Service instances restart simultaneously and all reach for the same Key Vault, things break.
In the era of blazing fast compute and memory, it’s easy for the performance characteristics of System objects to feel like a thing of the past.
At ~~Empower~~ Tilt, a data-driven fintech startup, our lifeblood is understanding our users and how they interact with our products.
More in Engineering