Back to Article List

ALTER TABLE in SQL Server: add, drop, rename columns

ALTER TABLE in SQL Server: add, drop, rename columns

Updated and tested 15 October 2026

Every ALTER TABLE statement has two costs. There's the one you measure in development, where the table holds 400 rows and the change completes in 30 milliseconds. Then there's the one you discover in production, when the identical statement lands on 90 million rows, takes a schema modification lock, and every query behind it stacks up in a queue that gets longer while you watch. The syntax for all of this fits on one screen. Knowing which variant is metadata-only and which one rewrites the table is the entire job.

Every ALTER TABLE in SQL Server below runs against the three tables this series has been building on, so if you've read the sibling articles the column names will be familiar.

CREATE TABLE dbo.customers (
    customer_id   int IDENTITY(1,1) NOT NULL PRIMARY KEY,
    company_name  nvarchar(200)  NOT NULL,
    contact_email varchar(255)   NULL,
    country_code  char(2)        NOT NULL,
    created_utc   datetime2(3)   NOT NULL DEFAULT (SYSUTCDATETIME())
);

CREATE TABLE dbo.orders (
    order_id     int IDENTITY(1,1) NOT NULL PRIMARY KEY,
    customer_id  int           NOT NULL REFERENCES dbo.customers (customer_id),
    order_date   datetime2(3)  NOT NULL,
    shipped_date datetime2(3)  NULL,
    status       varchar(20)   NOT NULL,
    total_amount decimal(12,2) NOT NULL
);

CREATE TABLE dbo.order_items (
    order_item_id int IDENTITY(1,1) NOT NULL PRIMARY KEY,
    order_id      int           NOT NULL REFERENCES dbo.orders (order_id),
    sku           varchar(40)   NOT NULL,
    quantity      int           NOT NULL,
    unit_price    decimal(12,2) NOT NULL
);

Add a column in SQL Server

The cheap case is a nullable column with no default. It's a metadata change, it doesn't touch a single page, and it finishes in milliseconds on a table of any size.

ALTER TABLE dbo.orders
ADD discount_amount decimal(12,2) NULL;

Note there's no COLUMN keyword on the way in. ADD column_name, but DROP COLUMN column_name. That asymmetry has caught me more than once. You can add several columns in one statement, comma separated, and all of them go on the end of the table.

Adding a NOT NULL column with a default

This is where edition matters. In Enterprise (and Evaluation, and any Developer edition with the Enterprise feature set), adding a NOT NULL column with a default is an online, metadata-only operation as long as the default is a runtime constant. In Standard and Express, the same statement writes the default value into every existing row.

-- Metadata-only on Enterprise, full table update elsewhere
ALTER TABLE dbo.orders
ADD is_priority bit NOT NULL CONSTRAINT DF_orders_is_priority DEFAULT (0);

-- Full table update on every edition: NEWID() differs per row
ALTER TABLE dbo.orders
ADD tracking_guid uniqueidentifier NOT NULL DEFAULT (NEWID());

A runtime constant is one value computed once for the whole statement. Literal 0 qualifies. SYSUTCDATETIME() qualifies. NEWID() does not, because it produces a fresh value per row, so that second statement rewrites the table on Enterprise as well. Max types, xml, the spatial types and anything that would push the row past the 8,060 byte limit also fall off the fast path.

Name the default constraint explicitly, the way the first statement does. Let SQL Server generate one and you get DF__orders__is_prio__3B75D760, a name that comes out different on every environment you deploy to, which makes the constraint impossible to drop from a scripted migration without first running a lookup query against sys.default_constraints, and that lookup is three joins you would rather not have written into a deployment step that runs unattended. Two seconds of typing now.

The hex on the end of a generated name is not random, and people have worked out what it maps to. I went looking once, on the theory that predicting the names would let me skip the lookup. It doesn't help, because the name still comes out different on each environment. I never followed up the rest of it.

ALTER TABLE in SQL Server: ALTER COLUMN and what blocks it

The syntax is short.

ALTER TABLE dbo.customers
ALTER COLUMN contact_email varchar(320) NULL;

Instant or full rewrite depends entirely on what you're changing. Widening a variable-length type, varchar(255) to varchar(320), is metadata only. Changing int to bigint physically rewrites every row, because the column's storage width changed. Moving an old datetime column to datetime2(3) rewrites it as well, and SQL Server date functions makes the case for why that migration is worth paying for, mostly because datetime rounds every value it stores onto a three-value millisecond grid and then reports a precision it does not have. Changing a column from NULL to NOT NULL scans the whole table to prove no NULLs exist. Same statement shape, four very different bills.

The longer problem is the list of things that make ALTER COLUMN refuse outright. Microsoft's ALTER TABLE reference spells them out, and it's worth reading the whole list once because the error messages don't always name the real obstacle. A column can't be altered if it's a timestamp, if it's the ROWGUIDCOL, if it's computed or is referenced by a computed column, if it appears in the output of CREATE STATISTICS, if it takes part in a PRIMARY KEY or FOREIGN KEY constraint, if it's in a CHECK or UNIQUE constraint (with an exception for lengthening a variable-length column), or if it has a default definition and you're changing more than length, precision or scale.

On top of that: you can't change the data type of a column in a partitioned table at all, you can't change the type of an indexed column unless it's varchar, nvarchar or varbinary growing to an equal or larger size, and you can't take a primary key column from NOT NULL to NULL.

Working order for a blocked ALTER COLUMN

Every one of those is removable and the order is always the same: take off what's blocking, alter, put it back. User-created statistics get forgotten more than anything else on the list, since auto-created statistics are dropped for you and manual ones are not.

-- 1. Find what's in the way
SELECT i.name AS index_name, i.type_desc
FROM sys.indexes AS i
JOIN sys.index_columns AS ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id
JOIN sys.columns AS c ON c.object_id = ic.object_id AND c.column_id = ic.column_id
WHERE i.object_id = OBJECT_ID('dbo.customers')
  AND c.name = 'contact_email';

SELECT s.name AS stats_name, s.auto_created
FROM sys.stats AS s
JOIN sys.stats_columns AS sc ON sc.object_id = s.object_id AND sc.stats_id = s.stats_id
JOIN sys.columns AS c ON c.object_id = sc.object_id AND c.column_id = sc.column_id
WHERE s.object_id = OBJECT_ID('dbo.customers')
  AND c.name = 'contact_email';

-- 2. Drop, alter, recreate
DROP INDEX IX_customers_contact_email ON dbo.customers;

ALTER TABLE dbo.customers
ALTER COLUMN contact_email varchar(320) NULL;

CREATE NONCLUSTERED INDEX IX_customers_contact_email
    ON dbo.customers (contact_email);

On Enterprise, SQL Server 2016 and later, WITH (ONLINE = ON) on the ALTER COLUMN lets the rewrite happen while queries keep running. On Standard it isn't available, the table is locked for the duration, and for a type change on a large table that is the whole argument for the copy-and-swap approach at the bottom of this page. I should say plainly that I have used ONLINE = ON on index rebuilds many times and on ALTER COLUMN not once, because every instance I look after day to day runs Standard.

Collation changes and a move from varchar to nvarchar deserve separate thought before you run them, since the comparison rules travel with the column. Going from case-insensitive to case-sensitive can break every existing WHERE clause written against that column without raising an error on a single one of them. What else shifts when a collation does is traced in SQL Server string functions.

Drop a column in SQL Server

ALTER TABLE dbo.orders
DROP COLUMN IF EXISTS legacy_ref;

IF EXISTS makes the statement rerunnable, which matters in a migration script that half-applied and got rolled back by hand. The blockers mirror the ALTER COLUMN list: a column can't be dropped while it's part of an index (key column or INCLUDE), while it participates in a CHECK, FOREIGN KEY, UNIQUE or PRIMARY KEY constraint, while it has a default constraint attached or while a rule is bound to it.

The default constraint is the usual culprit, and if it was auto-named you need to look it up first:

SELECT dc.name AS default_constraint
FROM sys.default_constraints AS dc
JOIN sys.columns AS c
  ON c.object_id = dc.parent_object_id
 AND c.column_id = dc.parent_column_id
WHERE dc.parent_object_id = OBJECT_ID('dbo.orders')
  AND c.name = 'legacy_ref';

ALTER TABLE dbo.orders DROP CONSTRAINT DF__orders__legacy___4AB81AF0;
ALTER TABLE dbo.orders DROP COLUMN legacy_ref;

Microsoft states it plainly: "Dropping a column doesn't reclaim the disk space of the column." The metadata says the column is gone and every page still carries the bytes. A table you dropped four nvarchar(4000) columns from is exactly as large as it was the day before. To get the space back you rebuild:

ALTER TABLE dbo.orders REBUILD;          -- rebuilds the table itself
ALTER INDEX ALL ON dbo.orders REBUILD;   -- rebuilds every index on it

That rebuild is a size-of-data operation and needs free space in the data file for a second copy of the table, because the old structure isn't released until the rebuild commits. Schedule it. Don't append it to the migration script and hope.

Your idea deserves better hosting

24/7 support 30-day money-back guaranteeRefund covers the VPS plan on your first order. Cancel anytime
Ciclo de Pagamento

VPS.S1

$5.99 Save  17 %
$4.99 Mensalmente
  • 2 vCPU AMD EPYC
  • 2 GB RAMMEMÓRIA
  • 30 GB NVMeDISCO
  • Largura de banda ilimitada
  • IPv4 & IPv6O suporte a IPv6 está indisponível de momento em França, Finlândia ou nos Países Baixos. incluídos

VPS.S3

$14.99 Save  33 %
$9.99 Mensalmente
  • 4 vCPU AMD EPYC
  • 6 GB RAMMEMÓRIA
  • 70 GB NVMeDISCO
  • Largura de banda ilimitada
  • IPv4 & IPv6O suporte a IPv6 está indisponível de momento em França, Finlândia ou nos Países Baixos. incluídos

VPS.S4

$19.99 Save  20 %
$15.99 Mensalmente
  • 6 vCPU AMD EPYC
  • 8 GB RAMMEMÓRIA
  • 80 GB NVMeDISCO
  • Largura de banda ilimitada
  • IPv4 & IPv6O suporte a IPv6 está indisponível de momento em França, Finlândia ou nos Países Baixos. incluídos

EPYC VPS.P1

$8.99 Save  22 %
$6.99 Mensalmente
  • 2 vCPU AMD EPYC
  • 4 GB RAMMEMÓRIA
  • 40 GB NVMeDISCO
  • Largura de banda ilimitada
  • IPv4 & IPv6O suporte a IPv6 está indisponível de momento em França, Finlândia ou nos Países Baixos. incluídos
  • Backup automático grátisInclui um espaço de backup que pode configurar para diário, semanal ou mensal.

EPYC VPS.P2

$16.99 Save  24 %
$12.99 Mensalmente
  • 2 vCPU AMD EPYC
  • 8 GB RAMMEMÓRIA
  • 80 GB NVMeDISCO
  • Largura de banda ilimitada
  • IPv4 & IPv6O suporte a IPv6 está indisponível de momento em França, Finlândia ou nos Países Baixos. incluídos
  • Backup automático grátisInclui um espaço de backup que pode configurar para diário, semanal ou mensal.

EPYC VPS.P4

$29.99 Save  23 %
$22.99 Mensalmente
  • 4 vCPU AMD EPYC
  • 16 GB RAMMEMÓRIA
  • 160 GB NVMeDISCO
  • Largura de banda ilimitada
  • IPv4 & IPv6O suporte a IPv6 está indisponível de momento em França, Finlândia ou nos Países Baixos. incluídos
  • Backup automático grátisInclui um espaço de backup que pode configurar para diário, semanal ou mensal.

EPYC VPS.P5

$39.99 Save  25 %
$29.99 Mensalmente
  • 8 vCPU AMD EPYC
  • 16 GB RAMMEMÓRIA
  • 180 GB NVMeDISCO
  • Largura de banda ilimitada
  • IPv4 & IPv6O suporte a IPv6 está indisponível de momento em França, Finlândia ou nos Países Baixos. incluídos
  • Backup automático grátisInclui um espaço de backup que pode configurar para diário, semanal ou mensal.

EPYC VPS.P6

$59.99 Save  25 %
$44.99 Mensalmente
  • 8 vCPU AMD EPYC
  • 32 GB RAMMEMÓRIA
  • 200 GB NVMeDISCO
  • Largura de banda ilimitada
  • IPv4 & IPv6O suporte a IPv6 está indisponível de momento em França, Finlândia ou nos Países Baixos. incluídos
  • Backup automático grátisInclui um espaço de backup que pode configurar para diário, semanal ou mensal.

EPYC VPS.P7

$69.99 Save  29 %
$49.99 Mensalmente
  • 16 vCPU AMD EPYC
  • 32 GB RAMMEMÓRIA
  • 240 GB NVMeDISCO
  • Largura de banda ilimitada
  • IPv4 & IPv6O suporte a IPv6 está indisponível de momento em França, Finlândia ou nos Países Baixos. incluídos
  • Backup automático grátisInclui um espaço de backup que pode configurar para diário, semanal ou mensal.

Genoa VPS.G2

$24.99 Save  20 %
$19.99 Mensalmente
  • 2 vCPUAMD EPYC Genoa 4ª geração 9xx4 com 3,25 GHz ou similar, na arquitetura Zen 4. AMD EPYC G4
  • 4 GB DDR5MEMÓRIA
  • 50 GB NVMeDISCO
  • Largura de banda ilimitada
  • IPv4 & IPv6O suporte a IPv6 está indisponível de momento em França, Finlândia ou nos Países Baixos. incluídos
  • Backup automático grátisInclui um espaço de backup que pode configurar para diário, semanal ou mensal.

Genoa VPS.G4

$44.99 Save  22 %
$34.99 Mensalmente
  • 4 vCPUProcessador AMD EPYC com núcleos vCPU dedicados, em hardware de servidor empresarial. AMD EPYC G4
  • 8 GB DDR5MEMÓRIA
  • 100 GB NVMeDISCO
  • Largura de banda ilimitada
  • IPv4 & IPv6O suporte a IPv6 está indisponível de momento em França, Finlândia ou nos Países Baixos. incluídos
  • Backup automático grátisInclui um espaço de backup que pode configurar para diário, semanal ou mensal.

Genoa VPS.G6

$89.99 Save  22 %
$69.99 Mensalmente
  • 8 vCPUProcessador AMD EPYC com núcleos vCPU dedicados, em hardware de servidor empresarial. AMD EPYC G4
  • 16 GB DDR5MEMÓRIA
  • 200 GB NVMeDISCO
  • Largura de banda ilimitada
  • IPv4 & IPv6O suporte a IPv6 está indisponível de momento em França, Finlândia ou nos Países Baixos. incluídos
  • Backup automático grátisInclui um espaço de backup que pode configurar para diário, semanal ou mensal.

Genoa VPS.G7

$159.99 Save  22 %
$124.99 Mensalmente
  • 8 vCPUProcessador AMD EPYC com núcleos vCPU dedicados, em hardware de servidor empresarial. AMD EPYC G4
  • 32 GB DDR5MEMÓRIA
  • 250 GB NVMeDISCO
  • Largura de banda ilimitada
  • IPv4 & IPv6O suporte a IPv6 está indisponível de momento em França, Finlândia ou nos Países Baixos. incluídos
  • Backup automático grátisInclui um espaço de backup que pode configurar para diário, semanal ou mensal.

AMD Ryzen VPS.R1

$16.99 Save  18 %
$13.99 Mensalmente
  • 1 CPU dedicado AMD Ryzen 9 7950X com 4,5 GHz ou similar, na arquitetura Zen 4. vCPU
  • 4 GB DDR5MEMÓRIA
  • 50 GB NVMeDISCO
  • Largura de banda ilimitada
  • IPv4 & IPv6 incluídos O suporte a IPv6 está indisponível de momento em França, Finlândia ou nos Países Baixos.
  • Backup automático incluído

AMD Ryzen VPS.R2

$29.99 Save  17 %
$24.99 Mensalmente
  • 2 CPU dedicados AMD Ryzen 9 7950X com 4,5 GHz ou similar, na arquitetura Zen 4. vCPU
  • 8 GB DDR5MEMÓRIA
  • 100 GB NVMeDISCO
  • Largura de banda ilimitada
  • IPv4 & IPv6 incluídos O suporte a IPv6 está indisponível de momento em França, Finlândia ou nos Países Baixos.
  • Backup automático incluído

AMD Ryzen VPS.R4

$109.99 Save  18 %
$89.99 Mensalmente
  • 8 CPU dedicados AMD Ryzen 9 7950X com 4,5 GHz ou similar, na arquitetura Zen 4. vCPU
  • 32 GB DDR5MEMÓRIA
  • 400 GB NVMeDISCO
  • Largura de banda ilimitada
  • IPv4 & IPv6 incluídos O suporte a IPv6 está indisponível de momento em França, Finlândia ou nos Países Baixos.
  • Backup automático incluído

*VAT excluded.

INCLUDED WITH EVERY PLAN

✓ No setup fees ✓1 Gbps network
✓ Full root access on KVM ✓ Free server monitoring ✓ Firewall management ✓24/7 support

Rename a column with sp_rename

To rename a column in SQL Server there is no ALTER TABLE ... RENAME COLUMN. The only tool is a system stored procedure, and the third argument is what tells it you mean a column and not a table.

EXEC sp_rename 'dbo.orders.order_ref', 'order_reference', 'COLUMN';

The old name is fully qualified, the new name is bare. Leave off 'COLUMN' and the procedure assumes you meant a table, which produces a confusing error or, worse, does something you didn't intend.

Microsoft attaches a Caution to the sp_rename reference that deserves quoting in full: "Changing any part of an object name can break scripts and stored procedures. We recommend you don't use this statement to rename stored procedures, triggers, user-defined functions, or views; instead, drop the object and re-create it with the new name."

The reason is that renaming updates the name and nothing else. Every procedure, view, function, check constraint and computed column that referenced the old name still contains the old text, and SQL Server will not tell you until something runs. So look before you rename:

SELECT OBJECT_SCHEMA_NAME(d.referencing_id) AS referencing_schema,
       OBJECT_NAME(d.referencing_id)        AS referencing_object,
       o.type_desc,
       d.referenced_entity_name
FROM sys.sql_expression_dependencies AS d
JOIN sys.objects AS o ON o.object_id = d.referencing_id
WHERE d.referenced_id = OBJECT_ID('dbo.orders');

And refresh afterwards. A view built with SELECT * caches its column list at creation time and keeps serving the old names until you rebuild its metadata, which is one of the more entertaining bugs to debug at speed.

EXEC sp_refreshsqlmodule 'dbo.usp_monthly_revenue';
EXEC sp_refreshview 'dbo.vw_open_orders';

For anything with real traffic on it I don't rename at all. Add the new column, write to both for a release, backfill the old data, point the readers at the new one, then drop the old column in a later deployment. The backfill is the only slow part, and the batching pattern from the UPDATE statement in SQL Server is what keeps it from turning into an outage of its own. Five steps, and not one of them can take the application down, which is a trade I will make every time against a rename that looks free right up to the moment a stored procedure nobody has opened since 2021 starts returning invalid column name.

Rename a table with sp_rename

EXEC sp_rename 'dbo.orders', 'sales_orders';

Same procedure, no third argument. To rename a table in SQL Server the new name must not be schema qualified, and that trips everyone the first time. EXEC sp_rename 'dbo.orders', 'dbo.sales_orders'; gives you a table literally called dbo.sales_orders, brackets and all, sitting inside the dbo schema. Renaming that back is an exercise you only want to do once.

To move a table between schemas, that's a different statement:

ALTER SCHEMA reporting TRANSFER dbo.sales_orders;

Foreign keys, indexes and triggers follow a renamed table without any work, since they're bound to the object ID and not the name. Everything written as text against the old name does not.

Column order in a SQL Server table

Columns come out in column_id order, ALTER TABLE ... ADD always appends to the end, and there is no supported statement to move a column to position three.

SELECT column_id, name, TYPE_NAME(user_type_id) AS data_type, is_nullable
FROM sys.columns
WHERE object_id = OBJECT_ID('dbo.orders')
ORDER BY column_id;

SSMS pretends otherwise. Drag a column up the list in the table designer and it will happily save the change, because behind the scenes it creates a new table with the order you asked for, copies every row across, drops the original and renames the new one into place. On a 90 million row table that's a full copy under a schema modification lock with no progress indicator. This is why Prevent saving changes that require table re-creation is switched on by default under Tools, Options, Designers, Table and Database Designers. People turn it off because it stops them saving, which is exactly the wrong lesson.

The plain answer is that column order has no effect on anything that matters. It isn't the physical storage order (SQL Server groups fixed-length columns ahead of variable-length ones in the row regardless of how you declared them), it has no bearing on index usage and it changes no query plan. The only thing that cares is SELECT *, and if a column's ordinal position is load-bearing in your application then the application has a bug that reordering the table will only postpone. When someone needs a specific column layout for an export, give them a view with the columns in that order and leave the table alone.

DROP TABLE IF EXISTS

DROP TABLE IF EXISTS arrived in SQL Server 2016 and replaced a three-line incantation with one line.

-- SQL Server 2016 and later
DROP TABLE IF EXISTS dbo.orders_staging;

-- SQL Server 2014 and earlier
IF OBJECT_ID('dbo.orders_staging', 'U') IS NOT NULL
    DROP TABLE dbo.orders_staging;

The 'U' in that second form means user table, and leaving it out will match a view or a procedure of the same name and then fail on the drop. Checking the SQL Server version tells you which form you need, though every supported version now has the short one, so the long form is only needed in a script that has to run against something out of support. You can drop several tables in one statement, comma separated. A table referenced by a foreign key can't be dropped until the referencing constraint goes.

TRUNCATE TABLE versus DELETE

Both empty a table. They differ in permissions, in what happens to the identity counter, in what they refuse to run against and in how much log they generate.

TRUNCATE TABLE dbo.orders_staging;

DELETE FROM dbo.orders_staging;

Start with permissions, because it's the difference people hit first. The minimum permission for TRUNCATE TABLE is ALTER on the table, and Microsoft notes those permissions "aren't transferable". There's no GRANT TRUNCATE to hand out. An application login with DELETE rights cannot truncate, and giving it ALTER so that it can means giving it the right to change the table's shape. That trade is usually a no, and a nightly job that needs to empty a staging table should run as a principal that legitimately owns it.

TRUNCATE resets the identity column to its seed. If order_item_id was up to 8,412,000, the next insert after a truncate is 1. Microsoft's line is short and clear: "To retain the identity counter, use DELETE instead." Plenty of quiet data corruption has come from a truncate on a table whose keys were referenced somewhere outside the database.

Then the refusals. You can't truncate a table referenced by a FOREIGN KEY constraint, and the constraint being disabled makes no difference at all, which surprises people who disable constraints as a warm-up. The documented exception is a foreign key that references its own table, which is allowed. Truncation is also blocked on a table participating in an indexed view, published by transactional or merge replication, defined as a system-versioned temporal table or referenced by an EDGE constraint.

Now the correction, because it's repeated in most comparison tables online and it's wrong. TRUNCATE TABLE is logged. What it records is page deallocations. Row-by-row deletions never reach the log, which is why it's fast, and it runs inside a transaction like anything else. Wrap it and roll it back and the rows come back:

BEGIN TRANSACTION;
    TRUNCATE TABLE dbo.orders_staging;
    SELECT COUNT(*) FROM dbo.orders_staging;   -- 0
ROLLBACK TRANSACTION;
SELECT COUNT(*) FROM dbo.orders_staging;       -- back to the original count

Microsoft's TRUNCATE TABLE reference carries the full list of restrictions. What TRUNCATE does take is a schema modification lock, so it waits behind every open reader on the table and blocks everything that arrives after it, which is the same behaviour that makes a badly timed ALTER TABLE dangerous and the same behaviour the last section of this page is about. DELETE takes row locks, which escalate but let some concurrency through. It also fires DELETE triggers, which TRUNCATE does not, and that is the check I make before swapping one for the other in a job somebody else wrote.

TRUNCATE with PARTITIONS

On a partitioned table you can empty individual partitions, which has been available since SQL Server 2016 and is the cleanest way to roll a sliding window of history.

TRUNCATE TABLE dbo.orders_archive
WITH (PARTITIONS (2, 4, 6 TO 8));

Individual numbers, ranges with TO, or both in one list.

Partitions you don't name are untouched.

Schema changes on a live database without a maintenance window

Almost every ALTER TABLE in SQL Server ends up requesting a schema modification lock, and so does nearly every other DDL statement on this page. A SCH-M lock is incompatible with everything, including the schema stability lock that a plain SELECT holds, and the escalation from slow statement to outage runs like this: your ALTER TABLE waits behind one long-running report, and every query arriving after your lock request queues behind that. Ten seconds of waiting becomes 400 blocked sessions and a connection pool at its limit.

So I set a lock timeout on any DDL that runs against a live table:

SET LOCK_TIMEOUT 5000;   -- milliseconds

ALTER TABLE dbo.orders
ADD discount_amount decimal(12,2) NULL;

Five seconds and the statement gives up with error 1222. Nothing sits there holding the door shut. Run it again in a quieter minute. I started doing this after a two-line ALTER TABLE on an orders table sat waiting behind a reporting query for eleven minutes, and the application spent nine of those minutes handing timeouts to customers over a change that would have cost nothing at all in a quiet hour.

The pattern for anything that isn't metadata-only is expand and contract. Add the new nullable column, deploy application code that writes to both the old and the new one, backfill the existing rows in batches small enough to keep each transaction short, then flip the readers over and drop the old column in a later release. Each step is independently reversible and none of them needs the application stopped.

-- Batched backfill: short transactions, no lock escalation
WHILE 1 = 1
BEGIN
    UPDATE TOP (4000) dbo.orders
    SET discount_amount = 0.00
    WHERE discount_amount IS NULL;

    IF @@ROWCOUNT = 0 BREAK;

    WAITFOR DELAY '00:00:00.200';
END

The WAITFOR is there to give other sessions a gap between batches. Four thousand rows keeps each transaction under the 5,000 lock escalation threshold, so lock count never pushes the statement to a table lock. When the change is a type conversion that ALTER COLUMN would rewrite anyway, I skip ALTER COLUMN entirely, build a new table alongside the old one, copy into it in batches, and swap the two with sp_rename inside a transaction at the end.

Use INSERT INTO in SQL Server for that copy and not SELECT INTO, because SELECT INTO creates the target for you and carries across none of the indexes, constraints or triggers you spent an afternoon getting right, which you find out about after the swap, when the first query against the new table scans 90 million rows.

Read data types, CAST and CONVERT before you pick the target column types, since getting them right before the copy starts is the whole point of doing it this way. A silent truncation discovered halfway through a batched copy is a much worse afternoon than an ALTER COLUMN that locks for ten minutes.

One last thing on migrations: run them in an explicit transaction with SET XACT_ABORT ON at the top. DDL in SQL Server is transactional, which is not true of MySQL, and it means a five-statement migration that fails on statement four leaves the database exactly where it started. You don't end up three statements in with two still missing. That property is worth more than most of the tooling built on top of it.

Questions?

How do I add a column only if it doesn't already exist?

ALTER TABLE has DROP COLUMN IF EXISTS but no matching ADD ... IF NOT EXISTS, so wrap the statement in a check against sys.columns: IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('dbo.orders') AND name = 'discount_amount') ALTER TABLE dbo.orders ADD discount_amount decimal(12,2) NULL;. A migration script written that way can run twice without failing. Older answers use COL_LENGTH(...) IS NULL for the same test, though COL_LENGTH also returns NULL on an error or a missing permission, so the catalog view gives the clearer answer.

Jörg L. Günther
About the author

Jörg L. Günther

Jörg has spent much of his career in enterprise software, working first with database deployments and customer environments, then in technical training and documentation. Since then he has written for companies working in virtualisation, database tooling and cloud infrastructure. The subjects he takes on often have a large operational component: database migrations, high-availability setups, storage configuration and procedures carried out on live systems. Jörg lives near Frankfurt. He plays piano and cycles for transport as much as for recreation. He's also been doing woodworking for several years - he says most of the furniture in his study is his own work.

Deploy your next project in seconds

Bring your winning ideas online faster, with modern hardware and unmetered bandwidth. Join a European cloud trusted by thousands of developers and businesses worldwide.