Mert Özen Further With Every Line
Database SQL Fundamentals — #25

Everyday DDL and Idempotent Scripts: IDENTITY and SEQUENCE, Computed Columns, CREATE OR ALTER, DROP IF EXISTS and Synonyms

Mert Özen Nov 15, 2026 17 min 9 views
Everyday DDL and Idempotent Scripts: IDENTITY and SEQUENCE, Computed Columns, CREATE OR ALTER, DROP IF EXISTS and Synonyms

A rerunnable deploy script ties every step to an OBJECT_ID/COL_LENGTH check, uses CREATE OR ALTER for views and procedures and DROP ... IF EXISTS for the rest, and wraps DDL in a transaction with XACT_ABORT ON. With real output: where identity and sequence gaps come from, IDENTITY_INSERT and CHECKIDENT, a shared order number sequence, PERSISTED computed columns, a view broken by sp_rename, and synonyms; the last post of the series.

Version note: The SQL queries in this post were run on SQL Server 2025 (17.0, Cumulative Update 10) with the series' Bookstore database; the PostgreSQL comparisons were run on PostgreSQL 18.7.

Guess First: Running the Deploy Script a Second Time

A two-line deploy script that adds a loyalty_tier column to customers for the loyalty programme and makes the phone number unique:

ALTER TABLE dbo.customers ADD loyalty_tier VARCHAR(10) NULL;
CREATE UNIQUE INDEX UX_customers_phone ON dbo.customers (phone);
Msg 1505, Level 16, State 1, Line 2
The CREATE UNIQUE INDEX statement terminated because a duplicate key was found for the object name 'dbo.customers' and the index name 'UX_customers_phone'. The duplicate key value is (<NULL>).
The statement has been terminated.

The index was not created: 21 customers have a NULL phone, and in a unique index SQL Server treats NULLs as equal to each other. A teammate sees the error and runs the script again. What happens?

ALTER TABLE dbo.customers ADD loyalty_tier VARCHAR(10) NULL;
CREATE UNIQUE INDEX UX_customers_phone ON dbo.customers (phone);
Msg 2705, Level 16, State 4, Line 1
Column names in each table must be unique. Column name 'loyalty_tier' in table 'dbo.customers' is specified more than once.
SELECT COL_LENGTH('dbo.customers', 'loyalty_tier') AS loyalty_tier_bytes,
       INDEXPROPERTY(OBJECT_ID('dbo.customers'), 'UX_customers_phone', 'IndexId') AS ux_index_id;
loyalty_tier_bytes  ux_index_id
------------------  -----------
10                  NULL

The first run stopped halfway: the column was added, the index was not. The second run now fails on the first line because the column already exists. The database is neither in its old nor in its new state, and the script can neither fix nor undo that. This is the last post of the series, and it deals with both sides of this problem: everyday DDL tools such as IDENTITY, SEQUENCE and computed columns, and deploy scripts that do not fail when they run twice and do not stop halfway. The examples run on the database of Bookstore, the online bookstore used as the shared example across this site's series.

IDENTITY: Seed, Increment and Gaps

IDENTITY(seed, increment) has been in the primary key of every Bookstore table since post 4. In post 22 we saw that IDENT_CURRENT did not go back after a rolled-back insert; let's see where gaps come from all at once:

SELECT IDENT_SEED('dbo.orders') AS seed, IDENT_INCR('dbo.orders') AS increment, IDENT_CURRENT('dbo.orders') AS current_value;
BEGIN TRAN;
INSERT INTO dbo.orders (customer_id, ordered_at, status) VALUES (1, '2026-06-30 10:00', 'Pending');
ROLLBACK;
INSERT INTO dbo.orders (customer_id, ordered_at, status) VALUES (1, '2026-06-30 10:00', 'Unknown');
INSERT INTO dbo.orders (customer_id, ordered_at, status) VALUES (1, '2026-06-30 10:00', 'Pending');
SELECT TOP (2) order_id, status FROM dbo.orders ORDER BY order_id DESC;
seed  increment  current_value
----  ---------  -------------
1     1          379

(1 rows affected)

(1 rows affected)
Msg 547, Level 16, State 1, Line 5
The INSERT statement conflicted with the CHECK constraint "CK_orders_status". The conflict occurred in database "sqlseries_ext_25", table "dbo.orders", column 'status'.
The statement has been terminated.

(1 rows affected)
order_id  status
--------  -------
382       Pending
379       Pending

The order after 379 became 382. 380 was used up by the rolled-back transaction and 381 by the insert that failed on CK_orders_status. An identity value is allocated at insert time, and neither a rollback nor an error gives it back; that is how concurrent sessions get numbers without waiting for each other. On top of that come values lost when the server restarts: for performance, SQL Server allocates identity values in advance and keeps them in memory, and on an unexpected shutdown those values are skipped. The database-level IDENTITY_CACHE setting (default 1 in sys.database_scoped_configurations) turns this off, at the cost of insert performance; even with it off, rollback gaps remain. The bottom line: identity is an identifier, not a sequence number. If there is a business rule that "order numbers must be consecutive", do not put it on identity.

The state of the identity is read, and changed when needed, with DBCC CHECKIDENT:

DBCC CHECKIDENT ('dbo.orders', NORESEED);
Checking identity information: current identity value '382', current column value '382'.
DBCC execution completed. If DBCC printed error messages, contact your system administrator.

To fill a gap by hand, or to move data from another system together with its ids, you need to write a value into the identity column. By default that is not allowed:

INSERT INTO dbo.orders (order_id, customer_id, ordered_at, status) VALUES (381, 1, '2026-06-30 10:00', 'Pending');
Msg 544, Level 16, State 1, Line 1
Cannot insert explicit value for identity column in table 'orders' when IDENTITY_INSERT is set to OFF.
SET IDENTITY_INSERT dbo.orders ON;
INSERT INTO dbo.orders (order_id, customer_id, ordered_at, status) VALUES (381, 1, '2026-06-30 10:05', 'Pending');
SET IDENTITY_INSERT dbo.orders OFF;
INSERT INTO dbo.orders (customer_id, ordered_at, status) VALUES (1, '2026-06-30 10:10', 'Pending');
SELECT TOP (3) order_id, ordered_at FROM dbo.orders ORDER BY order_id DESC;
order_id  ordered_at
--------  -------------------
383       2026-06-30 10:10:00
382       2026-06-30 10:00:00
381       2026-06-30 10:05:00

With IDENTITY_INSERT on, the column list is mandatory, and it can be on for only one table per session at a time. If the value you write is larger than the current value, identity continues from there; if it is smaller (like 381), identity stays where it is and the next insert gets 383. To move the counter back after cleaning up test data, use RESEED:

DELETE FROM dbo.orders WHERE order_id > 379;
DBCC CHECKIDENT ('dbo.orders', RESEED, 379);
INSERT INTO dbo.orders (customer_id, ordered_at, status) VALUES (1, '2026-06-30 10:15', 'Pending');
SELECT TOP (1) order_id FROM dbo.orders ORDER BY order_id DESC;
(3 rows affected)
Checking identity information: current identity value '383'.
DBCC execution completed. If DBCC printed error messages, contact your system administrator.

(1 rows affected)
order_id
--------
380

If you reseed below the current largest id, later inserts collide with existing ids and you get a primary key error. There are very few cases where you need RESEED in production; most of the time it is for cleanup in a test environment.

SEQUENCE: Numbers Independent of a Table

A SEQUENCE is a counter object that is not tied to a table; it hands out values with NEXT VALUE FOR. In Bookstore we use it for the order number shown to customers: separate from the id, starting at 100001, and shared by individual orders and institutional orders (the Institutional module). The script is written to be rerunnable from the start:

IF OBJECT_ID('dbo.order_number_seq', 'SO') IS NULL
    CREATE SEQUENCE dbo.order_number_seq AS INT START WITH 100001 INCREMENT BY 1 CACHE 50;
IF COL_LENGTH('dbo.orders', 'order_number') IS NULL
    ALTER TABLE dbo.orders ADD order_number INT NULL
        CONSTRAINT DF_orders_order_number DEFAULT (NEXT VALUE FOR dbo.order_number_seq);
DROP TABLE IF EXISTS dbo.institution_orders;
CREATE TABLE dbo.institution_orders (
    institution_order_id INT IDENTITY(1,1) CONSTRAINT PK_institution_orders PRIMARY KEY,
    institution_name     NVARCHAR(100) NOT NULL,
    order_number         INT NOT NULL CONSTRAINT DF_institution_orders_order_number DEFAULT (NEXT VALUE FOR dbo.order_number_seq)
);
GO
INSERT INTO dbo.orders (customer_id, ordered_at, status) VALUES (1, '2026-06-30 11:00', 'Pending'), (3, '2026-06-30 11:05', 'Pending');
INSERT INTO dbo.institution_orders (institution_name) VALUES (N'Hilltop School Library');
SELECT 'orders' AS source, order_id AS id, order_number FROM dbo.orders WHERE order_number IS NOT NULL
UNION ALL
SELECT 'institution_orders', institution_order_id, order_number FROM dbo.institution_orders
ORDER BY order_number;
source              id   order_number
------------------  ---  ------------
orders              381  100001
orders              382  100002
institution_orders  1    100003

The two tables took numbers from the same counter; you cannot do that with identity, because identity belongs to a column of a single table. We added the column as nullable, and the DEFAULT applied only to new rows; the existing 380 orders stayed NULL. If you also want to number old orders, do it as a separate, deliberate step (with NEXT VALUE FOR ... OVER (ORDER BY ordered_at) you can also fix the order). SEQUENCE has two more subtleties:

SELECT NEXT VALUE FOR dbo.order_number_seq AS peek_consumes;
DECLARE @first SQL_VARIANT, @last SQL_VARIANT;
EXEC sys.sp_sequence_get_range @sequence_name = N'dbo.order_number_seq', @range_size = 100,
     @range_first_value = @first OUTPUT, @range_last_value = @last OUTPUT;
SELECT @first AS range_first, @last AS range_last;
SELECT current_value, cache_size, is_cached FROM sys.sequences WHERE name = 'order_number_seq';
peek_consumes
-------------
100004

(1 rows affected)
range_first  range_last
-----------  ----------
100005       100104

(1 rows affected)
current_value  cache_size  is_cached
-------------  ----------  ---------
100104         50          1

Calling NEXT VALUE FOR to ask "what will the next number be?" uses that number up; 100004 will never be given to an order. If you only want to look, read sys.sequences.current_value. CACHE 50 allocates values in memory in blocks of 50 and does not write to disk for every value; the price is that the rest of the block is skipped on an unexpected shutdown. When the application needs many numbers at once, sys.sp_sequence_get_range reserves a range in one call: a request for 100 numbers returned the range 100005–100104 and current_value jumped to the end of the range; using those numbers is now the application's job. So a sequence has gaps too, just like identity. Numbers that must legally be gapless, such as invoice numbers, need a separate design; the Solution Patterns series post "Numbering: Invoice and Order Numbers, Gapless Sequences and Yearly Resets" covers that and yearly resets.

Computed Columns and PERSISTED

A computed column is calculated from an expression. By default it is not stored and is calculated on every read; with PERSISTED the calculated value is written to the row and kept up to date as the source columns change. The amount of an order line:

IF COL_LENGTH('dbo.order_items', 'line_total') IS NULL
    ALTER TABLE dbo.order_items ADD line_total AS (quantity * unit_price) PERSISTED;
GO
SELECT TOP (3) order_item_id, quantity, unit_price, line_total FROM dbo.order_items WHERE quantity > 1 ORDER BY order_item_id;
SELECT c.name, c.is_computed, cc.is_persisted, TYPE_NAME(c.system_type_id) AS type_name, c.precision, c.scale
FROM sys.columns AS c
LEFT JOIN sys.computed_columns AS cc ON cc.object_id = c.object_id AND cc.column_id = c.column_id
WHERE c.object_id = OBJECT_ID('dbo.order_items') AND c.name = 'line_total';
order_item_id  quantity  unit_price  line_total
-------------  --------  ----------  ----------
1              2         290.90      581.80
5              3         440.00      1320.00
10             2         460.00      920.00

(3 rows affected)
name        is_computed  is_persisted  type_name  precision  scale
----------  -----------  ------------  ---------  ---------  -----
line_total  1            1             decimal    21         2

The type comes from the expression: INT * DECIMAL(10,2) → DECIMAL(21,2) (the precision rules from post 22). You cannot write to a computed column directly; UPDATE ... SET line_total = 0 was rejected with Msg 271. To be PERSISTED, the expression must be deterministic, that is, it must always return the same result for the same input:

ALTER TABLE dbo.orders ADD age_days AS DATEDIFF(day, ordered_at, SYSUTCDATETIME()) PERSISTED;
Msg 4936, Level 16, State 1, Line 1
Computed column 'age_days' in table 'orders' cannot be persisted because the column is non-deterministic.

An order's age changes every day; storing it makes no sense. The version without PERSISTED can be added and is calculated on every read. Which one when: make values that are read often and are expensive to calculate PERSISTED; computed columns that you index must be deterministic, but they do not have to be PERSISTED (the JSON_VALUE column in post 23 was not persisted). In post 20 we saw that AT TIME ZONE cannot go into a persisted computed column for the same reason.

Putting DDL in a Transaction and Making It Rerunnable

Back to the script that stopped halfway in the opening. In SQL Server most DDL statements, such as ALTER TABLE and CREATE INDEX, run inside a transaction and can be rolled back. First let's remove the leftover column and put the same script in a transaction:

ALTER TABLE dbo.customers DROP COLUMN IF EXISTS loyalty_tier;
GO
SET XACT_ABORT ON;
BEGIN TRAN;
ALTER TABLE dbo.customers ADD loyalty_tier VARCHAR(10) NULL;
CREATE UNIQUE INDEX UX_customers_phone ON dbo.customers (phone);
COMMIT;
GO
SELECT COL_LENGTH('dbo.customers', 'loyalty_tier') AS loyalty_tier_bytes, @@TRANCOUNT AS open_tran;
Msg 1505, Level 16, State 1, Line 4
The CREATE UNIQUE INDEX statement terminated because a duplicate key was found for the object name 'dbo.customers' and the index name 'UX_customers_phone'. The duplicate key value is (<NULL>).
loyalty_tier_bytes  open_tran
------------------  ---------
NULL                0

Same error, but this time the column was rolled back too: the database is in its state from before the script. Now let's make the real fix. For customers without a phone we use a filtered index (an index that contains only the rows matching its WHERE condition), and we make every step depend on an "already done?" check. GO 2 runs the script twice in a row:

SET XACT_ABORT ON;
BEGIN TRAN;
IF COL_LENGTH('dbo.customers', 'loyalty_tier') IS NULL
    ALTER TABLE dbo.customers ADD loyalty_tier VARCHAR(10) NULL;
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE object_id = OBJECT_ID('dbo.customers') AND name = 'UX_customers_phone')
    CREATE UNIQUE INDEX UX_customers_phone ON dbo.customers (phone) WHERE phone IS NOT NULL;
COMMIT;
PRINT 'migration applied';
GO 2
SELECT COL_LENGTH('dbo.customers', 'loyalty_tier') AS loyalty_tier_bytes,
       INDEXPROPERTY(OBJECT_ID('dbo.customers'), 'UX_customers_phone', 'IndexId') AS ux_index_id;
migration applied
migration applied
loyalty_tier_bytes  ux_index_id
------------------  -----------
10                  3

Both runs finished without errors; in the second one neither IF found any work to do. The tools for the checks are the metadata functions from post 22: OBJECT_ID(name, type) for tables, views, procedures and sequences ('U' table, 'V' view, 'P' procedure, 'SO' sequence), COL_LENGTH for columns, sys.indexes for indexes, and sys.objects or OBJECT_ID for constraints.

There is one more trap in such scripts. Adding a column and using it in the same batch:

IF COL_LENGTH('dbo.orders', 'order_code') IS NULL
    ALTER TABLE dbo.orders ADD order_code AS CONCAT('BK-', order_number);
SELECT TOP (2) order_id, order_number, order_code FROM dbo.orders WHERE order_number IS NOT NULL ORDER BY order_id;
Msg 207, Level 16, State 1, Line 3
Invalid column name 'order_code'.

The batch is compiled as a whole; at compile time order_code does not exist, so the ALTER TABLE did not run either. This is the DDL counterpart of the batch rule from post 24: put the statement that uses the new column in the next batch (after GO).

IF COL_LENGTH('dbo.orders', 'order_code') IS NULL
    ALTER TABLE dbo.orders ADD order_code AS CONCAT('BK-', order_number);
GO
SELECT TOP (2) order_id, order_number, order_code FROM dbo.orders WHERE order_number IS NOT NULL ORDER BY order_id;
order_id  order_number  order_code
--------  ------------  ----------
381       100001        BK-100001
382       100002        BK-100002

In a script split into batches, a transaction can stay open across batches, but if XACT_ABORT rolls the transaction back in one batch, the following batches keep running outside a transaction. That is why you should run a multi-batch deploy script with sqlcmd and :on error exit (post 24) and write every step to be rerunnable; together the two make the "if it stops halfway, fix it and run again" flow safe.

CREATE OR ALTER and DROP ... IF EXISTS

Programmable objects (views, stored procedures, functions, triggers) need no check; CREATE OR ALTER has been available since SQL Server 2016 SP1. If the object does not exist it creates it; if it exists it changes its definition and keeps its permissions:

CREATE OR ALTER VIEW dbo.v_book_prices AS
SELECT book_id, title, list_price FROM dbo.books WHERE is_active = 1;
GO
CREATE OR ALTER VIEW dbo.v_book_prices AS
SELECT book_id, title, list_price, listed_on FROM dbo.books WHERE is_active = 1;
GO
SELECT TOP (2) * FROM dbo.v_book_prices ORDER BY book_id;
book_id  title                list_price  listed_on
-------  -------------------  ----------  ----------
1        Pride and Prejudice  230.90      2024-06-01
2        Emma                 680.50      2024-06-01

The old pattern, "DROP if it exists, then CREATE", also drops the permissions granted on the object, and between the two steps the object briefly does not exist; CREATE OR ALTER does neither. For tables, indexes and other objects the shortcut is DROP ... IF EXISTS:

ALTER TABLE dbo.customers DROP COLUMN IF EXISTS no_such_column;
ALTER TABLE dbo.customers DROP CONSTRAINT IF EXISTS no_such_constraint;
DROP INDEX IF EXISTS no_such_index ON dbo.customers;
DROP SEQUENCE IF EXISTS dbo.no_such_seq;
DROP SYNONYM IF EXISTS dbo.no_such_synonym;
PRINT 'nothing to drop, no error';
nothing to drop, no error

There is no CREATE OR ALTER for tables, and there cannot be: "changing" the definition of an existing table raises the question of what happens to the data. For tables the pattern IF OBJECT_ID(..., 'U') IS NULL CREATE TABLE applies, and for columns IF COL_LENGTH(...) IS NULL ALTER TABLE ... ADD. Use DROP TABLE IF EXISTS carefully in a deploy script: it is rerunnable, but it deletes all the data; it belongs only with temporary or staging tables.

sp_rename, Dependencies and Synonyms

Renaming a column is a single command, but you have to ask first who uses that name:

SELECT referencing_schema_name, referencing_entity_name FROM sys.dm_sql_referencing_entities('dbo.books', 'OBJECT');
referencing_schema_name  referencing_entity_name
-----------------------  -----------------------
dbo                      CK_books_list_price
dbo                      CK_books_page_count
dbo                      v_book_prices
EXEC sp_rename 'dbo.books.listed_on', 'listed_date', 'COLUMN';
GO
SELECT TOP (2) * FROM dbo.v_book_prices ORDER BY book_id;
Caution: Changing any part of an object name could break scripts and stored procedures.
Msg 207, Level 16, State 1, Procedure v_book_prices, Line 2
Invalid column name 'listed_on'.
Msg 4413, Level 16, State 1, Line 1
Could not use view or function 'dbo.v_book_prices' because of binding errors.

sp_rename only gave a warning and did its job; the breakage showed up the first time the view was used. The text inside views, procedures and functions is not updated; application code, reports and ETL jobs keep using the old name too. Let's undo it:

EXEC sp_rename 'dbo.books.listed_date', 'listed_on', 'COLUMN';
GO
SELECT TOP (1) book_id, listed_on FROM dbo.v_book_prices ORDER BY book_id;
Caution: Changing any part of an object name could break scripts and stored procedures.
book_id  listed_on
-------  ----------
1        2024-06-01

If you define the view WITH SCHEMABINDING, this silent breakage cannot happen: SQL Server refuses up front to rename a referenced column. When I tried to rename list_price, I got "Msg 15336 ... cannot be renamed because the object participates in enforced dependencies." for the same reason, because CK_books_list_price sits on it:

CREATE OR ALTER VIEW dbo.v_book_prices WITH SCHEMABINDING AS
SELECT book_id, title, list_price, listed_on FROM dbo.books WHERE is_active = 1;
GO
EXEC sp_rename 'dbo.books.listed_on', 'listed_date', 'COLUMN';
Msg 15336, Level 16, State 1, Procedure sp_rename, Line 774
Object 'dbo.books.listed_on' cannot be renamed because the object participates in enforced dependencies.

In production, a rename is usually done not in one step but with a transition period: the new column is added, the two columns live side by side for a while, reading and writing code is moved over, and finally the old column is dropped. When you move a table, a synonym helps. A synonym is an alias for another object; code uses the synonym, and you change the object behind it:

CREATE OR ALTER SYNONYM dbo.catalog_books FOR dbo.books;
Msg 102, Level 15, State 1, Line 1
Incorrect syntax near 'SYNONYM'.
DROP SYNONYM IF EXISTS dbo.catalog_books;
CREATE SYNONYM dbo.catalog_books FOR dbo.books;
SELECT COUNT(*) AS via_synonym FROM dbo.catalog_books;
SELECT name, base_object_name FROM sys.synonyms;
via_synonym
-----------
77

(1 rows affected)
name           base_object_name
-------------  ----------------
catalog_books  [dbo].[books]

CREATE OR ALTER is not supported for synonyms; the rerunnable form is DROP SYNONYM IF EXISTS + CREATE SYNONYM. Typical use: preparing catalog data in a new table (for example books_v2) in the background and switching the synonym to the new table in one short transaction, or pointing to a table in another database without changing the application's code. A synonym is a name redirect; if the columns of the table behind it change, the code still breaks.

SQL Server and PostgreSQL Counterparts

I ran the expressions in the PostgreSQL column in my own PostgreSQL 18 container:

SQL ServerBehaviourPostgreSQL
IDENTITY(1,1)Rollbacks and errors leave gapsGENERATED ALWAYS AS IDENTITY; OVERRIDING SYSTEM VALUE for explicit values
SET IDENTITY_INSERT ONOne table per sessionOVERRIDING SYSTEM VALUE (per statement)
CREATE SEQUENCE ... CACHE, NEXT VALUE FORHas gaps; reading consumesCREATE SEQUENCE, nextval()
Computed column, PERSISTEDPersisted needs determinismGENERATED ALWAYS AS (...); VIRTUAL by default in 18, STORED available
CREATE OR ALTERViews, procedures, functions, triggersCREATE OR REPLACE (views, functions, procedures)
DROP ... IF EXISTSSilently skips if missingDROP ... IF EXISTS, ADD COLUMN IF NOT EXISTS, CREATE INDEX IF NOT EXISTS
Unique indexDoes not allow more than one NULLNULLs count as distinct; NULLS NOT DISTINCT behaves like SQL Server
sp_rename columnDependent views breakALTER TABLE ... RENAME COLUMN; views keep working
SynonymAlias for an objectNone; search_path or a view

Two differences matter most for deploy scripts. In PostgreSQL the phone index from the opening was created without errors, because NULLs count as distinct from each other in a unique index. And in PostgreSQL almost all DDL runs inside a transaction: when I added a column in a transaction and then ran an insert that failed, the column was rolled back too. When I renamed a column, the view kept working; PostgreSQL stores the view against the column itself, not its name, and updates the definition to phone_number AS phone.

In Production

Every deploy script should answer two questions: what state is the database in if it stops halfway, and what happens if it runs a second time? The answers should be "the state before the script" and "nothing". For that, make every step depend on an existence check (OBJECT_ID, COL_LENGTH, sys.indexes, CREATE OR ALTER, DROP ... IF EXISTS), run the steps in a single transaction with XACT_ABORT ON where possible, and run multi-batch scripts with :on error exit. Put long-running, lock-holding steps on large tables, such as CREATE INDEX and column type changes, into a separate window and a separate script; online index operations and their locking behaviour are covered in the SQL Server Derinlemesine series.

Treat identity and sequence gaps as normal and do not base business rules on them. Before sp_rename, check dependencies with sys.dm_sql_referencing_entities, but remember that this query cannot see users outside the database (application, reports, ETL). Versioning schema changes and managing them with tools (SQL Database Projects, DbUp, Flyway) is the subject of the upcoming schema change post in the SQL Server Derinlemesine series; the patterns in this post are also the basis of the scripts those tools generate or run.

Common Pitfalls

  • Assuming identity or sequence values have no gaps; rollbacks, errors, caching and restarts leave gaps.
  • Calling NEXT VALUE FOR to see "what is the next number" and using the number up; read sys.sequences.current_value.
  • Letting a deploy script without a transaction or checks stop halfway; the second run fails with errors such as Msg 2705.
  • Adding a new column and using it in the same batch (Msg 207, the batch does not run at all).
  • Ignoring the warning from sp_rename; dependent views and procedures break on first use.
  • Trying to make a nondeterministic expression PERSISTED (Msg 4936).
  • Assuming a unique index allows more than one NULL; use a filtered index.
  • Making a table that holds data "rerunnable" with DROP TABLE IF EXISTS in a deploy script.

A Small Experiment

★☆☆ Write a script that adds a weight_grams column and a CHECK (weight_grams > 0) constraint to the books table and does not fail when it runs twice. Check whether the constraint exists with OBJECT_ID('dbo.CK_books_weight_grams', 'C').

★★☆ Give the existing 380 orders an order_number: the numbers should follow the order date, and when the script runs again it should not touch orders that already have a number. Then make order_number NOT NULL and add a unique constraint on it.

★★★ Write a script that copies dbo.books to dbo.books_v2 with list_price as DECIMAL(12,2), then switches the dbo.catalog_books synonym to the new table inside a single transaction. What happens if a view uses the synonym? Does sys.dm_sql_referencing_entities see what is behind the synonym?

With this post we complete the SQL Temelleri series. In 25 posts we covered the query language itself, starting from the relational model and data types and moving through SELECT, joins, grouping, subqueries, CTEs, window functions and data modification; then dialect differences, what SQL Server 2022 and 2025 added, the everyday T-SQL function catalog, JSON, writing scripts and deploy scripts. The SQL Server Derinlemesine series, which continues with the same Bookstore database, goes behind these queries: indexes, execution plans, statistics, transaction isolation and locks, stored procedures and tempdb. In this series you learned what a query returns; there you will learn why it is slow or why it is blocked.