Idempotent Data Patches in SQL Server: A Change Tracking Pattern for DACPAC Deployments

If you deploy your SQL Server databases with SSDT or DACPACs via SqlPackage, you have probably run into this problem: the schema deployment is repeatable, but the data patches are not.

SSDT handles CREATE TABLE, ALTER PROCEDURE, and every other schema object with a declarative model. Run the deployment ten times and you get the same schema. But the post-deployment script runs every time too, and if it contains INSERT or UPDATE statements, you either duplicate data or you wrap everything in IF NOT EXISTS checks that grow into an unreadable mess.

We solved this years ago with a small set of objects that give every data patch a name, a guard, and an audit trail. The pattern has survived hundreds of deployments across multiple environments without a single accidental re-application.

Illustration of a woman DBA beside a conveyor belt with labeled packages, a checkpoint gate routing processed packages to a skip lane, and a logbook recording each deployment

The Problem

Post-deployment scripts in SSDT run unconditionally. Every sqlpackage /Action:Publish executes the entire post-deployment entry point. If your script inserts a row, it inserts it again on the next deploy. If it updates a column, it overwrites whatever value is there now, even if someone changed it intentionally.

The typical workaround is ad-hoc guard clauses:

This works for one or two patches. After a year of deployments, your post-deployment script is hundreds of lines of nested IF blocks with no consistency, no audit trail, and no way to tell which patches have run in which environment.

The Solution: A Dedicated Change Tracking Schema

The pattern uses four objects in a dedicated schema (we use [deploy], but any name works):

1. The Registry Table

Every patch gets an [item_name] (the ticket number or patch identifier) and an [item_value] (typically N'patched', but you can version it). The identity starts at the int data type’s minimum value because why start half way through the range of possible values.

2. The Guard Function

Every patch script calls this function at the top. If it returns 1, the patch has already been applied and the entire block is skipped.

3. The Registration Procedure

The MERGE handles both first-run (INSERT) and re-run (UPDATE) cases. The OUTPUT clause captures the key for use in the audit log.

4. The Audit Log Table

This table records what each patch actually did: which table ([key_object]), which row ([key_value]), what kind of change ([change_type]: 1=insert, 2=update, 3=delete), and a human-readable description ([log_entry]).

The Pattern in Action

Here is a complete data patch script that follows the pattern. This example adds a configuration setting and logs the change:

Run this script ten times. The first run inserts the setting row, logs the change, and registers the patch. Runs two through ten print “Skipping (already applied)” and do nothing.

Why This Works Better Than Ad-Hoc Guards

Consistency. Every patch follows the same structure. New team members copy an existing patch, change the ticket number and the DML, and they are done. There is no ambiguity about how to make a patch idempotent.

Auditability. The [change_tracking] table tells you exactly which patches have run in any environment. The [change_log] table tells you what each patch did, down to the row level. When someone asks “did the PROJ-1234 fix get deployed to production?”, you query one table.

Atomicity. The guard check and the registration happen inside the same transaction. If the DML fails and the transaction rolls back, the patch is not registered and will retry on the next deployment.

Visibility. The RAISERROR ... WITH NOWAIT calls produce real-time output during deployment. You can watch sqlpackage run and see exactly which patches are executing and which are being skipped. No guessing.

Operational Queries

Once the pattern is in place, environment comparison becomes trivial:

Tips for Adopting This Pattern

Name patches after tickets. Use your issue tracker ID as the [item_name]. This creates a direct link between the deployment log and the work item that motivated the change.

Use the @commit flag for testing. Set @commit = 0 to test a patch without persisting. The script runs the DML, prints the output, and rolls back. Change it to 1 when you are ready to deploy for real.

Keep the [deploy] schema in your SSDT project. The four objects are part of the database schema. They deploy with the DACPAC and are version-controlled like everything else.

Do not delete rows from [change_tracking]. The table is an audit trail. If you need to re-run a patch (rare), update the [item_value] to something like N'reset' so the guard no longer matches, and the original registration is preserved.

This pattern has been running in production across multiple environments for several years. It is not clever or fancy. It is a small amount of infrastructure that eliminates an entire class of deployment problems.

Have questions about idempotent deployments or DACPAC post-deployment scripts? Let me know on Bluesky or LinkedIn.