Design and operations
How to design a schema that survives years of growth and how to run a database under load: normal forms and keys, data loads and reports, partitioning and schema changes on a live system. At the Senior level this is a conversation about trade-offs and risks, not syntax.
Before this topic: Indexes and query plans, Transactions and locking.
What is normalization, and when is denormalization justified?
Section titled “What is normalization, and when is denormalization justified?”What it is
Normalization is designing tables so that one fact is stored in one place. The store name lives in the Store table, and a sale references it by StoreId.
Normal forms
- 1NF. One value per cell, no repeating groups: not
Phone1,Phone2,Phone3columns but a separate phones table. - 2NF. With a composite key, a column depends on the whole key. In an order line
(OrderId, ProductId)the product name depends only onProductId— it belongs inProduct. - 3NF. A non-key column does not depend on another non-key column. A store’s address depends on the store, not on the sale.
Common mistake
Storing a comma-separated list in one column: 'A12,B7,C3'. You cannot index such a list, and searching turns into LIKE '%B7%'.
Takeaway: normalization puts every fact in one place, so a change is made once.
How it works
Normal forms remove three anomalies:
- insertion — you cannot add a store until it has sales;
- update — a store’s address has to change in thousands of rows, and some copies stay stale;
- deletion — you delete the last sale and lose the store’s data.
| Form | Requirement |
|---|---|
| 1NF | atomic values, no repeating groups |
| 2NF | non-key columns depend on the whole composite key |
| 3NF | non-key columns do not depend on each other |
| BCNF | every determinant is a candidate key |
When to choose what
- A transactional database — 3NF. Writes are cheap, data is consistent, indexes speed up reads.
- Denormalization — when you have measured that joins or aggregates on a hot path are expensive: a total in the receipt header, a product’s stock level, a reporting table.
Pitfalls
- History is not a duplicate. The price on a receipt line is the price at the time of sale, a separate fact. Pointing to the current price in the catalog would corrupt old receipts.
- A copy without an owner. A denormalized column is updated from several places and drifts from the source.
Rule of thumb
Start with 3NF; denormalize based on measurements and with an explicit sync mechanism.
Takeaway: normal forms remove anomalies, and denormalization is a conscious trade of consistency for read speed.
Under the hood
- A functional dependency
X → Y: the value ofXuniquely determinesY. 3NF allows a dependency ifYis part of a candidate key; BCNF forbids that too. - 3NF and BCNF diverge only with several overlapping composite keys, so in practice a table in 3NF is almost always already in BCNF.
Cost of denormalization
| Method | Who keeps it in sync | Cost |
|---|---|---|
PERSISTED computed column |
the engine | only expressions over its own row |
| Indexed view | the engine, in the same transaction | more expensive writes to base tables, many restrictions |
| Aggregate column: total, stock level | code or a trigger | hot row, risk of drift |
| Reporting table | a scheduled load | data lags behind |
Trade-offs
- Denormalization moves cost from reads to writes. A product’s stock level in a single row becomes a contention point: every sale of that product updates it.
- Reporting databases are denormalized on purpose: one load writes them, many queries read them.
- Overdoing it hurts too. The EAV model (“entity — attribute — value”) trades types and constraints for flexibility, and every attribute becomes a join.
How to check in production
- Regular reconciliation: the aggregate versus a recount from the source rows.
- A hot aggregate row shows up as
LCK_M_UandLCK_M_Xlock waits on a single key.
Takeaway: normalize for cheap and consistent writes; denormalize selectively when it is clear who keeps the copy in sync and how.
-- Before: the store name and address repeat in every sale (violates 3NF)CREATE TABLE dbo.SaleFlat ( SaleId bigint NOT NULL PRIMARY KEY, StoreId int NOT NULL, StoreName nvarchar(100) NOT NULL, StoreAddress nvarchar(200) NOT NULL, Amount decimal(12,2) NOT NULL);
-- After: the fact about the store is stored onceCREATE TABLE dbo.Store ( StoreId int NOT NULL PRIMARY KEY, Name nvarchar(100) NOT NULL, Address nvarchar(200) NOT NULL);
CREATE TABLE dbo.Sale ( SaleId bigint NOT NULL PRIMARY KEY, StoreId int NOT NULL REFERENCES dbo.Store (StoreId), SoldAt datetime2(0) NOT NULL, Amount decimal(12,2) NOT NULL);
-- A store moves: one row instead of thousandsUPDATE dbo.Store SET Address = N'New address' WHERE StoreId = 42;- How does 3NF differ from BCNF? — BCNF requires every determinant to be a candidate key; 3NF allows a dependency if the dependent column is part of a key.
- Does the price on a receipt line break normalization? — No, it is the price at the time of sale, a separate historical fact.
- How do you denormalize without losing consistency? — Let the engine keep it in sync (computed column, indexed view); otherwise a single owner of the copy plus reconciliation.
- What is EAV and why is it avoided? — “Entity — attribute — value”: flexible, but without types or constraints, and with a join per attribute.
- Why are reporting databases denormalized? — One load writes them and many queries read them; fewer joins mean faster aggregates.
Which primary key should you choose: identity, GUID or a natural key?
Section titled “Which primary key should you choose: identity, GUID or a natural key?”What it is
| Kind of key | Example |
|---|---|
| Numeric surrogate | int or bigint IDENTITY |
| GUID surrogate | uniqueidentifier |
| Natural | product code, tax ID, store code |
How to use
- The default is
bigint IDENTITY(1, 1): compact, increasing, never changes. - The natural key stays as a column with a
UNIQUEconstraint: duplicates are forbidden, but foreign keys reference the surrogate. - A GUID — when the ID is needed before the insert: the application or another database creates it.
Common mistake
Making a barcode or a tax ID the primary key. Barcodes get reused, tax IDs get mistyped, and then you have to change the key in every referencing table.
Takeaway: a surrogate key is for relationships, a natural key goes under a unique constraint.
How it works
- A primary key creates a clustered index by default: the table’s rows are stored ordered by the key.
- The clustered index key acts as the row locator in every nonclustered index. A 16-byte GUID instead of an 8-byte
bigintmakes every index on the table bigger. - An increasing key appends rows to the last page. A random
NEWID()inserts in the middle: the page splits in half, and the index becomes fragmented and half empty.
When to choose what
| Key | When |
|---|---|
bigint IDENTITY |
the default, one database |
SEQUENCE |
the number is needed before the insert or shared across tables |
| Sequential GUID | the application creates the ID, data merges from several databases |
NEWID() |
almost never as a clustered key |
Pitfalls
- Gaps in
IDENTITY. A rolled-back transaction or a server restart leaves gaps. You cannot get gapless receipt numbers this way. NEWSEQUENTIALID()works only in aDEFAULT, and after a Windows restart it can start from a lower value.- UUIDv7.
Guid.CreateVersion7()is time-ordered for .NET but not for auniqueidentifierin SQL Server.
Rule of thumb
bigint IDENTITY by default; a GUID when the application creates the ID, and then one that is sequential in SQL Server’s order.
Takeaway: the key is part of every index, so it should be short, immutable and increasing.
Under the hood
- SQL Server does not compare
uniqueidentifierleft to right: the last 6 bytes are the most significant. In UUIDv7 the timestamp sits in the first 6 bytes, so the insert order into the index is almost random. NEWSEQUENTIALID()andSequentialGuidValueGenerator(EF Core’s default generator for SQL Server) shuffle bytes to match that order.- UUIDv7 keeps its order in a
binary(16)written big-endian:ToByteArray(bigEndian: true)(.NET 8+).binarycompares byte by byte.
Cost
- An increasing key on a hot table hits last-page contention (
PAGELATCH_EX).OPTIMIZE_FOR_SEQUENTIAL_KEY(SQL Server 2019) smooths it out. - A GUID is 16 bytes in every index and foreign key: with hundreds of millions of rows that is gigabytes of buffer pool.
- Sequential IDs are easy to guess: a public order number is kept separate from the internal key.
Trade-offs
- A natural key saves joins, but changing it cascades through every reference. The exception is short immutable codes such as an ISO currency code.
- The primary key does not have to be clustered: make a GUID key nonclustered and cluster on an increasing column.
IDENTITYis known only after the insert, so batch-inserting related rows needsOUTPUTor aSEQUENCE.
How to check in production
sys.dm_db_index_physical_stats shows fragmentation and page fullness (avg_page_space_used_in_percent), and PAGELATCH_EX waits on one page reveal contention at the end of the index.
Takeaway: choose the key by size, insert order and where it is generated; a “sequential” GUID has to be sequential in SQL Server’s comparison order.
CREATE TABLE dbo.Product ( -- Surrogate key: 8 bytes, increasing, never changes ProductId bigint IDENTITY(1, 1) NOT NULL CONSTRAINT PK_Product PRIMARY KEY CLUSTERED, -- Natural key: unique, but foreign keys don't reference it Sku varchar(20) NOT NULL CONSTRAINT UQ_Product_Sku UNIQUE, Name nvarchar(200) NOT NULL);
CREATE TABLE dbo.Receipt ( -- GUID that is sequential in SQL Server's comparison order ReceiptId uniqueidentifier NOT NULL CONSTRAINT DF_Receipt_Id DEFAULT NEWSEQUENTIALID() CONSTRAINT PK_Receipt PRIMARY KEY CLUSTERED, StoreId int NOT NULL, CreatedAt datetime2(0) NOT NULL);
-- Hot inserts at the end of the index (SQL Server 2019+)ALTER INDEX PK_Product ON dbo.Product SET (OPTIMIZE_FOR_SEQUENTIAL_KEY = ON);using System.Data.SqlTypes;
var ids = new List<Guid>();for (var i = 0; i < 5; i++){ ids.Add(Guid.CreateVersion7()); // .NET 9+: time in ms + random bits Thread.Sleep(2); // within one ms the order is not guaranteed}
Console.WriteLine(ids[0].Version); // 7Console.WriteLine(ids.SequenceEqual(ids.Order())); // True: in .NET the order follows time
// SqlGuid compares like SQL Server: the last 6 bytes are most significant, and they are randomvar asSqlServer = ids.OrderBy(id => new SqlGuid(id));Console.WriteLine(ids.SequenceEqual(asSqlServer)); // False (almost always)
// binary(16) compares byte by byte: big-endian keeps the time ordervar asBinary = ids.OrderBy(id => Convert.ToHexString(id.ToByteArray(bigEndian: true)), StringComparer.Ordinal);Console.WriteLine(ids.SequenceEqual(asBinary)); // TrueSqlGuid from System.Data.SqlTypes follows SQL Server’s comparison rules, so it shows how UUIDv7 values land in an index on a uniqueidentifier.
- Why is a random GUID bad for a clustered key? — Inserts in the middle of the index: page splits, fragmentation, half-empty pages, 16 bytes in every index.
- Why does
IDENTITYhave gaps? — Values are not returned on rollback and are lost from the cache on a server restart. - Is
Guid.CreateVersion7()a good fit for SQL Server? — Withuniqueidentifierthe order is lost; useNEWSEQUENTIALID(), the EF Core generator or store it asbinary(16). - Does the primary key have to be clustered? — No: make it nonclustered and build the clustered index on another column.
- When is a natural key fine as the primary key? — When it is short and never changes: an ISO currency code, a country code.
How does ETL work, and how are loads built in SSIS?
Section titled “How does ETL work, and how are loads built in SSIS?”What it is
ETL is three steps:
- Extract — read data from a source: a database, a file, an API.
- Transform — clean it and bring it to the target format: types, lookups, duplicates.
- Load — write it to the target database.
How an SSIS package is built
- Control flow — tasks and the order they run in: “truncate staging”, “load data”, “run a procedure”.
- Data flow — a task that rows flow through: source → transformations → destination.
How to use
You develop a package in Visual Studio, deploy it to the SSISDB catalog on the server and run it on a schedule with a SQL Server Agent job.
Common mistake
Reloading the whole table every time. It works while the data is small, and a year later the load no longer fits in the nightly window.
Takeaway: ETL is extract, transform, load; in SSIS the control flow sets the order, and rows go through the data flow.
How it works
An incremental load step by step:
- Read the last watermark from a control table.
- The data flow pulls rows newer than it and bulk-inserts them into staging.
- A procedure moves staging into the target with
MERGEorUPDATEplusINSERT. - Save the new watermark in the same transaction.
When to choose what
| Method | Pro | Con |
|---|---|---|
| Watermark | simple, no setup on the source | does not see deletes |
| Change tracking | cheap, sees deletes | keys only, no old values |
| CDC | before and after values from the log | SQL Server Agent, load on the log |
Pitfalls
- Row-by-row processing. OLE DB Command and Slowly Changing Dimension run a query per row. Large volumes are processed in staging with one statement.
- Blocking transformations. Sort and Aggregate wait for the whole stream and hold it in memory. Push sorting to the source (
ORDER BYplusIsSortedon the output). - Modified date as a watermark. The time is set before commit, so rows from a long transaction slip through.
Rule of thumb
A run is safe to repeat: staging is truncated at the start, rows move by business key, and the watermark advances only on success.
Takeaway: the increment finds the changes, and staging plus a set-based move make reruns safe.
Under the hood
- The data flow works with in-memory buffers. Synchronous transformations (Derived Column, Lookup) change rows in the same buffer, asynchronous ones copy them into new buffers.
- OLE DB Destination in fast load mode inserts in batches;
Maximum insert commit sizesets the transaction size. - Checkpoints restart a package from the failed control-flow task, but not from the middle of a data flow, and Foreach loops run again. Idempotent steps are more reliable.
Trade-offs
- Change tracking is synchronous: every write pays for the bookkeeping. If the last sync is older than
CHANGE_TRACKING_MIN_VALID_VERSION, you need a full reload. - CDC is asynchronous, but the log cannot be truncated until the capture job has processed it.
- SSIS 2025 removed the Attunity CDC components and Microsoft Connector for Oracle and deprecated 32-bit mode. For new loads Microsoft points to Azure Data Factory and Data Factory in Fabric; existing packages run in ADF on the Azure-SSIS Integration Runtime.
- A load in .NET (
SqlBulkCopyinto staging, a procedure withMERGE) is easier to test and version, but logging, restarts and parallelism are yours to write.
How to check in production
- The SSISDB catalog keeps run history:
catalog.executionsandcatalog.event_messages, and with the Performance logging level, component timings incatalog.execution_component_phases. - After a load, reconcile row counts and totals between source and target.
Takeaway: reliable ETL means a lossless increment, an idempotent move and observability; the tool comes second.
SET XACT_ABORT ON;
DECLARE @from binary(8) = (SELECT LastVersion FROM etl.Watermark WHERE TableName = N'Sale');-- Upper bound: rows from in-flight transactions wait for the next runDECLARE @to binary(8) = MIN_ACTIVE_ROWVERSION();
-- Extract: in SSIS this is a data flow from the source into stagingTRUNCATE TABLE stg.Sale;INSERT INTO stg.Sale (SaleId, StoreId, Amount)SELECT SaleId, StoreId, AmountFROM src.SaleWHERE RowVer >= @from AND RowVer < @to;
-- Load: a rerun creates no duplicatesBEGIN TRAN;
MERGE dw.Sale AS tUSING stg.Sale AS s ON t.SaleId = s.SaleIdWHEN MATCHED THEN UPDATE SET t.StoreId = s.StoreId, t.Amount = s.AmountWHEN NOT MATCHED THEN INSERT (SaleId, StoreId, Amount) VALUES (s.SaleId, s.StoreId, s.Amount);
-- The watermark moves together with the dataUPDATE etl.Watermark SET LastVersion = @to WHERE TableName = N'Sale';
COMMIT;If you take @@DBTS instead of MIN_ACTIVE_ROWVERSION(), a row from a transaction that commits later gets a version below the saved bound and is never loaded.
- How does change tracking differ from CDC? — CT synchronously stores the keys of changed rows and a version; CDC asynchronously reads the log and stores before and after values.
- Why can’t
@@DBTSbe the watermark? — Rows from in-flight transactions commit with a version below the bound and get skipped. - How do you restart a failed SSIS package? — Checkpoints from the failed control-flow task; idempotent steps that can simply run again are more reliable.
- How do you use different connection strings for test and production? — Project parameters and environments in the SSISDB catalog.
- How do you speed up a data flow? — Remove row-by-row and blocking transformations, fast load into staging, push sorting and joins to the database.
How do you build reporting so that reports don’t take down the production database?
Section titled “How do you build reporting so that reports don’t take down the production database?”What it is
| OLTP | OLAP | |
|---|---|---|
| Queries | short, by key | aggregates over millions of rows |
| Example | a sale at the checkout | revenue per store for the year |
| Schema | normalized | denormalized, star |
Why separate them
A heavy report takes the production database’s CPU and disk and can block writes. Checkouts and the website start waiting.
How to use
- Reports are built in SSRS: a report is defined in an RDL file, and the report server runs the query and renders the result to a browser, Excel or PDF.
- Heavy reports connect not to the production database but to a copy of it or to a data warehouse.
Common mistake
Fixing report blocking with the NOLOCK hint. The report starts reading uncommitted data and can skip rows or count them twice.
Takeaway: OLTP is for transactions, OLAP is for analysis; heavy reports move off the production database.
How it works
A report hurts the production database in three ways:
- Resources. Scans and sorts take CPU, disk and query memory.
- Cache. The pages it reads push hot data out of the buffer pool.
- Locks. Under READ COMMITTED a read takes shared locks and conflicts with writes.
When to choose what
| Option | Data latency | When |
|---|---|---|
| RCSI on the production database | none | reports are light, only locking hurts |
| SSRS caching and snapshots | minutes to a day | many people open the same report |
| Always On replica | seconds | operational reports, Enterprise |
| Data mart | until the next ETL run | analytics, history, heavy aggregates |
Pitfalls
- A replica has the same schema. An index for a report is created on the primary and slows down writes. Your own indexes are possible only in a data mart or a replication subscriber.
- Unbounded parameters. An “all time” period by default turns a report into a full scan.
- Subscriptions at the same time. Hundreds of deliveries at 9:00 are a load spike that is easy to spread out.
Rule of thumb
First find the heavy reports, then move them by acceptable latency: operational ones to a replica, analytics to a data mart.
Takeaway: separate reports from transactions by resources and locking, and pick the approach by acceptable data latency.
Under the hood
- A readable secondary applies the log with a redo thread. All queries on it run under snapshot isolation, and locking hints are ignored.
- The primary pays for it: with a readable secondary, changed rows get 14 extra bytes for versioning, which can cause page splits.
- A long report on the replica holds Sch-S and can delay redo of DDL and ghost cleanup on the primary.
- Columnstore stores data by column with compression and processes rows in batches. Since SQL Server 2016 a nonclustered columnstore on a production table is updatable — this is real-time operational analytics.
Trade-offs
| Option | Cost |
|---|---|
| Always On replica | Enterprise; same schema and indexes as the primary; lag grows under load |
| Columnstore on a production table | every write costs more, space for the index |
| Data mart with a star schema | ETL, latency, one more data model |
A basic availability group in Standard gives you no readable secondary.
Report server in 2025+
SSRS 2022 is the last version, supported until January 11, 2033. Since SQL Server 2025 on-premises reporting means Power BI Report Server: the same RDL reports plus interactive PBIX, and the right to use it comes with Enterprise and Standard licenses. The cloud path is Power BI, where RDL files run as paginated reports.
How to check in production
ExecutionLog3in the report server database shows data retrieval, processing and rendering time for every run.- Replica lag —
sys.dm_hadr_database_replica_states:log_send_queue_size,redo_queue_size.
Takeaway: separate reporting from transactions by acceptable latency, and pick the platform knowing that the SSRS line has ended.
-- Report server database: ReportServer by default, the log keeps 60 daysUSE ReportServer;
-- Which reports waited on the database the longest this weekSELECT TOP (10) ItemPath, COUNT(*) AS Runs, SUM(CAST(TimeDataRetrieval AS bigint)) / 1000 AS DataSecTotal, AVG(CAST(TimeDataRetrieval AS bigint)) / 1000 AS DataSecAvg, MAX([RowCount]) AS MaxRowsFROM dbo.ExecutionLog3WHERE TimeStart >= DATEADD(DAY, -7, SYSDATETIME())GROUP BY ItemPathORDER BY DataSecTotal DESC;-- Operational analytics right on the production table (SQL Server 2016+):-- transactions write to the regular index, aggregates read compressed columnsCREATE NONCLUSTERED COLUMNSTORE INDEX NCCI_Sale ON dbo.Sale (StoreId, SoldAt, Amount);
SELECT StoreId, SUM(Amount) AS RevenueFROM dbo.SaleWHERE SoldAt >= '2026-10-01'GROUP BY StoreId;- How does a star schema differ from the production schema? — A fact table and denormalized dimensions built for aggregates versus normalized tables built for writes.
- Why doesn’t a report on a replica block writes? — On a readable secondary all queries run under snapshot isolation, and locking hints are ignored.
- Can you create an index only on an Always On replica? — No, the schema is shared; create it on the primary or use a data mart or replication.
- What happens to SSRS after SQL Server 2025? — No new versions; SSRS 2022 is supported until 2033, and the replacement is Power BI Report Server.
- How do you find the report that loads the database? —
ExecutionLog3in the report server database: data retrieval time and number of runs.
How do you scale a database: partitioning, replicas, sharding and archiving?
Section titled “How do you scale a database: partitioning, replicas, sharding and archiving?”What it is
- Partitioning — a table is stored in partitions by a column’s value, for example by sale month. For queries it is still one table.
- Read replica — a copy of the database that reports are moved to.
- Sharding — data for different stores or customers lives in different databases on different servers.
- Archiving — old data moves from the working table to an archive.
Why
A large table takes longer to maintain, back up and clean. Deleting last year with DELETE means hours of locks and gigabytes of log, while with partitioning it is a partition switch.
Common mistake
Expecting partitioning to speed up queries by itself. If the filter does not include the partitioning column, the query reads every partition and can get slower.
Takeaway: partitioning makes a large table easier to manage, replicas offload reads, sharding splits data across servers.
How partitioning works
- A partition function sets the boundaries:
RANGE RIGHT FOR VALUES ('2026-09-01', '2026-10-01'). - A partition scheme maps partitions to filegroups.
- The table and its indexes are created on the scheme. An index on the same scheme is aligned;
SWITCHworks only with aligned indexes.
Sliding window for archiving
SPLIT RANGEadds an empty partition for the next month.SWITCH PARTITIONmoves the oldest month into an archive table with the same structure.MERGE RANGEremoves the boundary that is now empty.
When to choose what
| Problem | Solution |
|---|---|
| The table takes too long to maintain and clean | partitioning |
| Reads overload the server | read replicas |
| Writes do not fit on one server | sharding |
Pitfalls
- A unique index includes the partitioning column, otherwise it is not aligned. Hence the composite key
(SaleId, SoldOn). - A query without the partitioning key seeks into every partition. Microsoft states plainly that partitioning rarely improves query performance in OLTP.
SPLITandMERGEon a non-empty partition move rows and write log, so the edge partitions are kept empty.
Takeaway: partitioning is a maintenance and archiving tool; read and write scale come from replicas and shards.
Under the hood
SWITCHchanges which table the pages belong to; no data is copied. Requirements: identical columns and indexes, the same filegroup, an empty target; when switching into a partition, the source proves the range with a CHECK constraint.SWITCHneeds an exclusive lock on both tables. Since SQL Server 2014,WAIT_AT_LOW_PRIORITYkeeps it from queuing ahead of every query.TRUNCATE TABLE ... WITH (PARTITIONS (2))(SQL Server 2016+) empties a partition without an archive. Partitioning is available in every edition since SQL Server 2016 SP1.
Cost
- Up to 15,000 partitions, but thousands of partitions cost memory, and queries without partition elimination get slower.
- Readable replicas are Enterprise only; a basic availability group in Standard does not allow reads on the secondary.
Sharding trade-offs
| What you lose | What you pay with |
|---|---|
| Cross-shard joins and aggregates | fan-out queries, reports through a warehouse |
| Cross-shard transactions | sagas, outbox, idempotency |
| Global uniqueness | GUIDs or a key with a shard number |
| Even distribution | rebalancing, “hot” shards |
Reference data such as products and stores is copied to every shard.
How to check in production
sys.dm_db_partition_statsshows rows per partition, and the plan showsActual Partition Count.- Replica lag —
sys.dm_hadr_database_replica_states.
Takeaway: squeeze one server to the limit, partition for maintenance, and shard only when writes have outgrown the server and there is a natural partition key.
-- RANGE RIGHT: the boundary is the first day of the month; keep the edge partitions emptyCREATE PARTITION FUNCTION pfMonth (date) AS RANGE RIGHT FOR VALUES ('2026-08-01', '2026-09-01', '2026-10-01', '2026-11-01');CREATE PARTITION SCHEME psMonth AS PARTITION pfMonth ALL TO ([PRIMARY]);
CREATE TABLE dbo.Sale ( SaleId bigint NOT NULL, SoldOn date NOT NULL, Amount decimal(12,2) NOT NULL, CONSTRAINT PK_Sale PRIMARY KEY (SaleId, SoldOn) -- the key includes the partitioning column) ON psMonth (SoldOn);
-- Archive: same structure and filegroup, emptyCREATE TABLE dbo.SaleArchive ( SaleId bigint NOT NULL, SoldOn date NOT NULL, Amount decimal(12,2) NOT NULL, CONSTRAINT PK_SaleArchive PRIMARY KEY (SaleId, SoldOn)) ON [PRIMARY];
-- 1. An empty partition for December while there is no November data yetALTER PARTITION SCHEME psMonth NEXT USED [PRIMARY];ALTER PARTITION FUNCTION pfMonth() SPLIT RANGE ('2026-12-01');
-- 2. August (partition 2) goes to the archive: only metadata changesALTER TABLE dbo.Sale SWITCH PARTITION 2 TO dbo.SaleArchive WITH (WAIT_AT_LOW_PRIORITY (MAX_DURATION = 1 MINUTES, ABORT_AFTER_WAIT = SELF));
-- 3. Remove the boundary between two empty partitionsALTER PARTITION FUNCTION pfMonth() MERGE RANGE ('2026-08-01');- Does partitioning speed up queries? — Only with partition elimination on the partitioning key; otherwise it often slows them down.
- Why is
SWITCHinstant? — Only the metadata about page ownership changes; rows are not copied. - Why keep the edge partitions empty? — Then
SPLITandMERGEmove no rows and do not bloat the log. - How does sharding differ from partitioning? — Partitions live in one database on one server, shards in different databases on different servers.
- How do you choose a shard key? — By the boundary that contains almost every transaction, with an even load: store, region, customer.
How do you change a database schema without downtime?
Section titled “How do you change a database schema without downtime?”What it is
A zero-downtime schema change means the database changes while the application runs and the stores keep selling.
How to use
Renaming the Phone column to PhoneNumber step by step:
- Add a
PhoneNumbercolumn that allowsNULL. The old application does not notice it. - Release an application version that writes to both columns.
- Copy the old data in small batches.
- Release a version that reads only
PhoneNumber. - Drop
Phoneonce no old application versions remain.
Common mistake
Renaming or dropping a column in the same release as the code. Until every server is updated, the old version queries a column that no longer exists and fails.
Takeaway: add first, then switch, and remove at the very end.
How it works
Expand/contract has three phases:
- Expand. Only compatible changes: a new nullable column, a table, an index. The old code keeps working.
- Migrate. The code writes to both old and new, a backfill moves the history, and reads switch to the new.
- Contract. Once no old code is left and the rollback window has passed, the old parts are removed.
How long the lock is held
| Operation | How it runs |
|---|---|
| New nullable column | metadata, instant |
| NOT NULL with a constant default | metadata in Enterprise (2012+), otherwise a rewrite of every row |
Type change, for example int → bigint |
rewrite of every row under Sch-M |
Index with ONLINE = ON |
online, short locks at the start and end; Enterprise |
Pitfalls
- The Sch-M queue.
ALTERwaits on a long report, and every new query on the table waits onALTER. SetSET LOCK_TIMEOUTbefore DDL and retry. - One big
UPDATE. A backfill in one transaction bloats the log, triggers lock escalation to the table and delays replicas. Use batches of a few thousand rows. - Renaming in one step.
sp_renameon a column the running version reads is a breaking change.
Rule of thumb
Every migration is compatible with the current and the previous application version.
Takeaway: expand/contract keeps every step compatible, and batches plus online operations keep locks short.
Under the hood
- Sch-M is incompatible with every other lock, even the schema stability lock that readers take. What matters is how long DDL holds it and how long it waits to get it.
- Escalation kicks in at roughly 5,000 locks taken by one statement on one object. Batches below that threshold keep row locks and do not stop everyone else.
- ADR (SQL Server 2019+) makes rolling back a long transaction instant and truncates the log while it runs; without ADR, cancelling an hour-long
UPDATEcan take just as long.
Changing a type on a large table
- Add a nullable column of the new type.
- Sync new changes — in code or with a trigger.
- Backfill in batches by key range.
- Switch reads and writes, then drop the old column.
ALTER COLUMN ... WITH (ONLINE = ON) (SQL Server 2016+, Enterprise) keeps the table available, but every row is still rewritten.
Trade-offs
- State-based deployment (dacpac,
sqlpackage) builds the script by comparing schemas and for some changes rebuilds the table by copying it, so the script is reviewed before production. Migrations (EF Core, DbUp) are explicit, but the order of steps is on you. - After expand you roll back only the code: the schema is compatible with the old version. Down migrations that lose data are replaced in production with fixing forward.
- A sync trigger catches every writer, including ETL, but makes every write more expensive.
How to check in production
- During a backfill, watch
log_reuse_wait_descinsys.databasesand the replicas’redo_queue_size; the pause between batches is the throttle. - A DDL queue shows up in
sys.dm_exec_requests:wait_typeLCK_M_SCH_Mandblocking_session_id.
Takeaway: zero downtime means compatibility with two code versions, short locks and a controlled Sch-M queue.
-- DDL waits for a lock no longer than 5 seconds and does not hold a queue behind itSET LOCK_TIMEOUT 5000;
-- Expand: a new nullable column, wider than the old one — metadata onlyALTER TABLE dbo.Receipt ADD TotalAmount decimal(14,2) NULL;
-- Backfill in batches by key range: short transactions, no escalationDECLARE @batch bigint = 4000, @from bigint = 0, @max bigint;SELECT @max = MAX(ReceiptId) FROM dbo.Receipt;
WHILE @from < @maxBEGIN UPDATE dbo.Receipt SET TotalAmount = Total WHERE ReceiptId > @from AND ReceiptId <= @from + @batch AND TotalAmount IS NULL; -- a rerun picks up where it stopped
SET @from += @batch; WAITFOR DELAY '00:00:00.100'; -- room for the production workload and replicasEND;
-- Contract — in a separate release, once the code no longer reads the old column-- ALTER TABLE dbo.Receipt DROP COLUMN Total;While the backfill runs, the new application version writes to both columns, so new receipts are already filled in.
- Why can a millisecond
ALTER TABLEtake the system down? — It waits for Sch-M behind a long query, and every new query queues behind it. - How do you add a NOT NULL column to a billion-row table? — In Enterprise with a constant default it is metadata only; otherwise a nullable column, a batched backfill, and the final
NOT NULLchecks every row. - Why batches of a few thousand rows? — Short transactions, no escalation to the table, the log can be truncated, replicas keep up.
- How do you roll back a release with a schema change? — Roll back the code: after expand the schema is compatible with the old version; the drop comes in a separate later release.
- How does a dacpac differ from migrations? — The script is generated by comparing schemas and can rebuild a table; migrations are explicit, but the order of steps is on you.