{"id":7538,"date":"2026-09-03T12:09:04","date_gmt":"2026-09-03T19:09:04","guid":{"rendered":"https:\/\/devblogs.microsoft.com\/azure-sql\/?p=7538"},"modified":"2026-09-03T13:09:52","modified_gmt":"2026-09-03T20:09:52","slug":"schema-change","status":"publish","type":"post","link":"https:\/\/devblogs.microsoft.com\/azure-sql\/schema-change\/","title":{"rendered":"Advocating for Uptime: The 6 Phases of Change"},"content":{"rendered":"<p><strong>Schema change is inevitable.<\/strong> We do not get everything right the first time, and even when we do, the world around the database keeps changing. Requirements mature, products evolve, and assumptions that made sense years ago eventually stop matching reality.<\/p>\n<p><strong>Businesses change too.<\/strong> We acquire companies, merge with other customers and systems, enter new markets, and adapt to new opportunities. Sometimes a schema has to change because the original design was wrong. More often, it changes because the business is no longer the same business that existed when the schema was designed.<\/p>\n<p><strong>That is normal.<\/strong> The challenge is not avoiding schema change. The challenge is making those changes without interrupting the applications and users that depend on the database.<\/p>\n<p><div class=\"alert alert-success\"><p class=\"alert-divider\"><i class=\"fabric-icon fabric-icon--Lightbulb\"><\/i><strong>Spoiler alert<\/strong><\/p>If there is anything that should be said about maintaining uptime through schema change, it should be: don&#8217;t get in a hurry. A sensible approach is a phased approach. It&#8217;s tempting to jump ahead, but read through this article and you&#8217;ll hopefully conclude that an intentional approach preserves integrity and eliminates downtime.<\/div><\/p>\n<h2>A simple example<\/h2>\n<p>Consider something simple: a <code>Name<\/code> column in a <code>User<\/code> table needs to become <code>FirstName<\/code> and <code>LastName<\/code>. The final schema is straightforward. Getting there safely in production is not. If uptime matters, this becomes a multi-phase migration where old and new application versions can coexist while the database evolves underneath them.<\/p>\n<p><a href=\"https:\/\/devblogs.microsoft.com\/azure-sql\/wp-content\/uploads\/sites\/56\/2026\/09\/table-to-table.webp\"><img decoding=\"async\" class=\"alignnone size-full wp-image-7540\" src=\"https:\/\/devblogs.microsoft.com\/azure-sql\/wp-content\/uploads\/sites\/56\/2026\/09\/table-to-table.webp\" alt=\"table to table image\" width=\"1498\" height=\"572\" srcset=\"https:\/\/devblogs.microsoft.com\/azure-sql\/wp-content\/uploads\/sites\/56\/2026\/09\/table-to-table.webp 1498w, https:\/\/devblogs.microsoft.com\/azure-sql\/wp-content\/uploads\/sites\/56\/2026\/09\/table-to-table-300x115.webp 300w, https:\/\/devblogs.microsoft.com\/azure-sql\/wp-content\/uploads\/sites\/56\/2026\/09\/table-to-table-1024x391.webp 1024w, https:\/\/devblogs.microsoft.com\/azure-sql\/wp-content\/uploads\/sites\/56\/2026\/09\/table-to-table-768x293.webp 768w\" sizes=\"(max-width: 1498px) 100vw, 1498px\" \/><\/a><\/p>\n<h2>Phase 1: Add columns <code>FirstName<\/code> and <code>LastName<\/code><\/h2>\n<p>The safest first step is also the simplest: add the new columns without changing anything the application already depends on.<\/p>\n<pre><code class=\"language-sql\">ALTER TABLE dbo.[User]\r\nADD\r\n    FirstName nvarchar(100) NULL,\r\n    LastName  nvarchar(100) NULL;<\/code><\/pre>\n<p>At this point, <code>Name<\/code> remains exactly where it was. Existing application instances continue reading and writing it, while the new columns simply wait for the next phase.<\/p>\n<p>This is the \u201cexpand\u201d part of the expand-and-contract pattern. We are making the schema larger before making it smaller. Nothing has been renamed, removed, or made incompatible.<\/p>\n<h3>Making the new columns nullable is intentional.<\/h3>\n<p>Existing rows do not have values for them yet, and requiring values immediately would turn a harmless additive change into a migration problem. We will populate them later, validate them, and only then decide whether stronger constraints make sense.<\/p>\n<p><div class=\"alert alert-info\"><p class=\"alert-divider\"><i class=\"fabric-icon fabric-icon--Info\"><\/i><strong>The important idea is sequencing<\/strong><\/p>Deploy the database change first. Once every production database understands both the old and new schema, we can safely begin deploying application code that understands both as well.<\/div><\/p>\n<h2>Phase 2: Dual-Write Old and New Columns<\/h2>\n<p>Now that the database understands both shapes, the application can begin writing both.<\/p>\n<pre><code class=\"language-csharp\">user.Name = $\"{user.FirstName} {user.LastName}\";\r\nuser.FirstName = firstName;\r\nuser.LastName = lastName;<\/code><\/pre>\n<p>The exact implementation will vary, but the principle is the same: every insert or update that affects a user&#8217;s name must keep <code>Name<\/code>, <code>FirstName<\/code>, and <code>LastName<\/code> synchronized.<\/p>\n<p>This phase is what allows old and new application versions to coexist. Older instances can continue reading <code>Name<\/code>. Newer instances can begin working with <code>FirstName<\/code> and <code>LastName<\/code>. Because both representations are written together, neither version sees stale data.<\/p>\n<p><div class=\"alert alert-info\"><p class=\"alert-divider\"><i class=\"fabric-icon fabric-icon--Info\"><\/i><strong>There is a subtle sequencing requirement here<\/strong><\/p>Start dual-writing before you start reading from the new columns. Existing rows have not been backfilled yet, so FirstName and LastName may still be NULL. Dual-write only guarantees that data changed from this point forward is correct in both representations.<\/div><\/p>\n<h3>You may be tempted to solve this with a trigger.<\/h3>\n<p>That can work, but I generally prefer putting the synchronization in the application when possible. The application understands the semantics of the change, keeps the migration logic visible, and makes it easier to remove later. A trigger can be useful when multiple applications write directly to the table and you cannot update all of them at once.<\/p>\n<h3>You may be tempted to solve this with a stored procedure.<\/h3>\n<p>That can work well, especially if all writes already flow through a stored procedure. In that case, updating one database boundary may be simpler than changing every caller. The procedure can accept <code>FirstName<\/code> and <code>LastName<\/code>, continue populating <code>Name<\/code>, and preserve compatibility while applications migrate.<\/p>\n<p>The downside is similar to any abstraction introduced only for a migration: it becomes another layer that has to be deployed, understood, and eventually removed. I would use it when stored procedures are already part of the application&#8217;s write path, but I would not introduce one solely to avoid a straightforward application change.<\/p>\n<p><strong>At the end of this phase<\/strong>, every new write is safe for both the old schema and the new schema. The historical data is the only thing left behind.<\/p>\n<h2>Phase 3: Backfill Existing Rows<\/h2>\n<p>Now that every new write keeps both representations synchronized, we can turn our attention to the rows that already existed before the application change.\u00a0The goal is simple: populate <code>FirstName<\/code> and <code>LastName<\/code> from <code>Name<\/code> without interfering with normal production traffic.<\/p>\n<pre><code class=\"language-sql\">UPDATE dbo.[User]\r\nSET\r\n    FirstName = LEFT(Name, CHARINDEX(' ', Name + ' ') - 1),\r\n    LastName = LTRIM(SUBSTRING(\r\n        Name,\r\n        CHARINDEX(' ', Name + ' ') + 1,\r\n        LEN(Name)\r\n    ))\r\nWHERE FirstName IS NULL\r\n   OR LastName IS NULL;<\/code><\/pre>\n<p><div class=\"alert alert-primary\"><p class=\"alert-divider\"><i class=\"fabric-icon fabric-icon--Info\"><\/i><strong>Real names are messy. <\/strong><\/p>The parsing logic here is intentionally simple for the example. Prefixes, suffixes, compound surnames, single-word names, and cultural naming conventions can make splitting a name surprisingly difficult. In a real migration, this transformation deserves its own validation and may not be practical in T-SQL.<\/div><\/p>\n<p>The more important production concern is how much data we update at once. A single large <code>UPDATE<\/code> can hold locks, grow the transaction log, and create unnecessary pressure on the system. <strong>For a large table, backfill in batches.<\/strong><\/p>\n<pre><code class=\"language-sql\">WHILE 1 = 1\r\nBEGIN\r\n    UPDATE TOP (1000) dbo.[User]\r\n    SET\r\n        FirstName = LEFT(Name, CHARINDEX(' ', Name + ' ') - 1),\r\n        LastName = LTRIM(SUBSTRING(\r\n            Name,\r\n            CHARINDEX(' ', Name + ' ') + 1,\r\n            LEN(Name)\r\n        ))\r\n    WHERE FirstName IS NULL\r\n       OR LastName IS NULL;\r\n\r\n    IF @@ROWCOUNT = 0\r\n        BREAK;\r\nEND<\/code><\/pre>\n<p>This is one of the benefits of dual-write happening first. While the backfill works through historical rows, every new insert or update is already maintaining the new columns. The migration is moving forward without requiring the application to stop.<\/p>\n<p><strong>At the end of this phase<\/strong>, old rows and new rows have the same shape. The database now contains both representations for every user, which means we can safely begin changing how the application reads the data.<\/p>\n<h2>Phase 4: Move Reads to the New Columns<\/h2>\n<p>At this point, every row has values in <code>FirstName<\/code> and <code>LastName<\/code>, and every new write keeps those columns synchronized with <code>Name<\/code>. Now the application can begin reading from the new schema.<\/p>\n<pre><code class=\"language-sql\">SELECT\r\n    Id,\r\n    FirstName,\r\n    LastName,\r\n    Email\r\nFROM dbo.[User];<\/code><\/pre>\n<p>This should still be treated as a deployment phase, not a cleanup phase. The <code>Name<\/code> column remains in place because older application instances may still be running, and other consumers may still depend on it.<\/p>\n<p>The safest approach is to deploy the read change gradually and verify that nothing breaks. If your application supports staged rollout, canary deployment, or feature flags, this is a good place to use them. The database now supports both shapes, so the application can move forward without forcing every consumer to change at the same moment.<\/p>\n<p>Once all supported application versions are reading <code>FirstName<\/code> and <code>LastName<\/code>, the primary application no longer depends on <code>Name<\/code>.<\/p>\n<h3>You might be tempted to remove read permissions from the old column.<\/h3>\n<p>That can seem like a useful way to flush out anything still depending on <code>Name<\/code>, but in production it turns discovery into an outage. Reports, jobs, scripts, or older application instances may still be reading the column.<\/p>\n<p>A safer approach is to observe usage, update known consumers, and leave the old column readable until you are confident nothing depends on it. Permissions are better used to enforce the final state than to test whether you missed something.<\/p>\n<p><div class=\"alert alert-success\"><p class=\"alert-divider\"><i class=\"fabric-icon fabric-icon--Lightbulb\"><\/i><strong>How to observe usage?<\/strong><\/p>Query Store is a good place to start because it can reveal recent queries still referencing the old column. Application logs, database logs, dependency metadata, and temporary Extended Events can also help identify active consumers. None is complete by itself, so combine approaches and use the tooling appropriate for your environment.<\/div><\/p>\n<h3>Don&#8217;t forget about reports<\/h3>\n<p>Your application may not be the only thing reading this table. Reports, ETL jobs, exports, scripts, scheduled jobs, notebooks, and downstream services may still reference <code>Name<\/code>.<\/p>\n<p>Before declaring the old column obsolete, search for those dependencies and give their owners time to migrate. This is one reason the phased approach matters: keeping <code>Name<\/code> available costs very little, while removing it too early can break consumers you did not know existed.<\/p>\n<p><strong>At the end of this phase<\/strong>, reads have moved to the new schema, but writes still maintain both representations. That gives us one more compatibility window before we stop maintaining <code>Name<\/code>.<\/p>\n<h2>Phase 5: Stop Writing <code>Name<\/code><\/h2>\n<p>By now, the application reads from <code>FirstName<\/code> and <code>LastName<\/code>, and we have given other consumers time to move away from <code>Name<\/code>. The next step is to stop maintaining the old representation.\u00a0Remove the dual-write logic so inserts and updates write only the new columns.<\/p>\n<pre><code class=\"language-csharp\">user.FirstName = firstName;\r\nuser.LastName = lastName;<\/code><\/pre>\n<p>At this point, <code>Name<\/code> becomes stale by design. That is okay because nothing should depend on it anymore.<\/p>\n<h3>This phase is another useful checkpoint.<\/h3>\n<p>Do not remove the column yet. Leaving <code>Name<\/code> in place for a while gives you time to confirm that no application, report, job, or downstream process unexpectedly starts failing once the old value stops changing.\u00a0The important distinction is that <code>Name<\/code> is now deprecated, not deleted. We have stopped investing in its correctness before taking the irreversible step of removing it.<\/p>\n<p><strong>At the end of this phase<\/strong>, the new schema is the only schema being actively maintained. The old column remains only as a compatibility buffer while we validate that the transition is complete.<\/p>\n<h2>Phase 6: Validate and Remove the Old Schema<\/h2>\n<p><strong>Congratulations!<\/strong> At this point, the migration is functionally complete. The application reads and writes only <code>FirstName<\/code> and <code>LastName<\/code>, and <code>Name<\/code> has been left in place long enough to prove that nothing still depends on it.<\/p>\n<p><div class=\"alert alert-info\"><p class=\"alert-divider\"><i class=\"fabric-icon fabric-icon--Info\"><\/i><strong>Before removing the old column<\/strong><\/p>Before removing the old column, validate the new state. Confirm that expected rows have values, investigate any unexpected NULL values, verify that reports and downstream consumers have moved, and add any final constraints or indexes that belong on the new columns.<\/div><\/p>\n<p>Once that validation is complete, the old column can finally be removed.<\/p>\n<pre><code class=\"language-sql\">ALTER TABLE dbo.[User]\r\n    DROP COLUMN Name;<\/code><\/pre>\n<p><strong>This is the \u201ccontract\u201d part of expand-and-contract.<\/strong> We expanded the schema first, allowed old and new versions to coexist, moved writes, backfilled data, moved reads, stopped maintaining the old shape, and only now remove it.\u00a0The important point is that dropping the column should be boring. By the time you execute this statement, nothing should notice.<\/p>\n<h2 class=\"PDq2pG_selectionAnchorContainer\" data-section-id=\"1xr3n5n\" data-start=\"429\" data-end=\"456\">The Pattern Is the Point<\/h2>\n<p data-start=\"458\" data-end=\"666\">This example is intentionally simple, but the pattern scales. Expand the schema first. Let old and new representations coexist. Move writes, move data, move reads, validate, and only then contract the schema.\u00a0<strong>The individual SQL statements are easy<\/strong>. The <em>discipline<\/em> is in the sequencing.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Schema changes are inevitable, but downtime doesn\u2019t have to be. Learn a phased approach to safely evolve a production SQL schema while applications remain online.<\/p>\n","protected":false},"author":96788,"featured_media":7554,"comment_status":"open","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"_acf_changed":false,"footnotes":""},"categories":[1,619],"tags":[528,573,661,747],"class_list":["post-7538","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-azure-sql","category-t-sql","tag-database-change-management","tag-migration","tag-schema","tag-strategy"],"acf":[],"blog_post_summary":"<p>Schema changes are inevitable, but downtime doesn\u2019t have to be. Learn a phased approach to safely evolve a production SQL schema while applications remain online.<\/p>\n","_links":{"self":[{"href":"https:\/\/devblogs.microsoft.com\/azure-sql\/wp-json\/wp\/v2\/posts\/7538","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/devblogs.microsoft.com\/azure-sql\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/devblogs.microsoft.com\/azure-sql\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/devblogs.microsoft.com\/azure-sql\/wp-json\/wp\/v2\/users\/96788"}],"replies":[{"embeddable":true,"href":"https:\/\/devblogs.microsoft.com\/azure-sql\/wp-json\/wp\/v2\/comments?post=7538"}],"version-history":[{"count":1,"href":"https:\/\/devblogs.microsoft.com\/azure-sql\/wp-json\/wp\/v2\/posts\/7538\/revisions"}],"predecessor-version":[{"id":7574,"href":"https:\/\/devblogs.microsoft.com\/azure-sql\/wp-json\/wp\/v2\/posts\/7538\/revisions\/7574"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/devblogs.microsoft.com\/azure-sql\/wp-json\/wp\/v2\/media\/7554"}],"wp:attachment":[{"href":"https:\/\/devblogs.microsoft.com\/azure-sql\/wp-json\/wp\/v2\/media?parent=7538"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/devblogs.microsoft.com\/azure-sql\/wp-json\/wp\/v2\/categories?post=7538"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/devblogs.microsoft.com\/azure-sql\/wp-json\/wp\/v2\/tags?post=7538"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}