Generating Idempotent SQL Scripts from EF Core
The release pipeline for a .NET service needs to migrate four databases — two staging environments, a production primary and a disaster-recovery copy — each sitting at a different migration version because of past hotfixes and skipped deploys. Running dotnet ef database update from the pipeline means installing the SDK and the source code on the runner and trusting whatever EF decides at run time. What you want instead is a single artefact, reviewed once in the pull request, that brings any of those databases up to date and does nothing to a database that is already current. dotnet ef migrations script --idempotent produces exactly that. This guide shows how to generate it, what the generated SQL does, the edge cases around non-transactional statements, and how to wire it into a deploy. It is a core practice in Entity Framework Core Migrations.
Symptom / Error Signatures
You need an idempotent script when you see these:
- A plain (non-idempotent) script, generated from migration 0 to latest, fails on an existing database with
There is already an object named 'Orders' in the database(SQL Server) or42P07: relation "orders" already exists(PostgreSQL). - Scripts generated for a specific
from/torange have to be regenerated per environment because each database is at a different version. - Deploy runners need the .NET SDK, the
dotnet-eftool and the application source just to rundatabase update. - Reviewers approve C# migration classes without ever seeing the SQL that runs.
Root Cause Analysis
A normal dotnet ef migrations script emits the statements for a range of migrations and assumes the target database is exactly at the start of that range. That assumption breaks whenever environments diverge. The --idempotent flag changes the generated SQL: every migration’s statements are wrapped in a conditional that checks whether its ID is already in __EFMigrationsHistory, and the insert into that table is inside the same block. The script can therefore be generated once, from the beginning of history to the latest migration, and run anywhere.
On PostgreSQL the conditionals are DO $EF$ BEGIN IF NOT EXISTS (SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '...') THEN ... END IF; END $EF$; blocks; on SQL Server they are IF NOT EXISTS (...) BEGIN ... END; batches separated by GO. Both work well for ordinary DDL. The catch is statements that cannot run inside those wrappers or inside a transaction — CREATE INDEX CONCURRENTLY in PostgreSQL cannot run inside a DO block at all, because a DO block executes within a transaction — so migrations that use migrationBuilder.Sql(..., suppressTransaction: true) for such statements need attention in idempotent scripts.
| Script type | Assumes | Safe to rerun | Works with non-transactional SQL |
|---|---|---|---|
| plain, full range | empty database | no | yes (statements run as written) |
plain, from–to |
database exactly at from |
no | yes |
--idempotent, full range |
nothing | yes | needs care (see below) |
| migration bundle | nothing | yes | yes (bundle runs migrations like database update) |
Immediate Mitigation
1. Generate the idempotent script in CI. Build it from the start of history to the latest migration, so it is valid for any database.
# Shell · CI build job with the .NET SDK and dotnet-ef · reads the compiled model; no database connection
dotnet tool restore
dotnet ef migrations script --idempotent \
--project src/Shop.Data --startup-project src/Shop.Api \
--output artifacts/migrate.sql
2. Review the script, not the C#. Publish migrate.sql as a build artefact and link or paste the new blocks into the pull request. Scan it mechanically for dangerous statements:
# Shell · CI step after script generation · touches no database
# WARNING: a pattern check is a backstop; human review of new blocks is still required.
if grep -nPi "DROP (TABLE|COLUMN)|ALTER COLUMN .* TYPE|CREATE (UNIQUE )?INDEX (?!CONCURRENTLY)" artifacts/migrate.sql; then
echo "review required: destructive or blocking statement in migrate.sql"; exit 1
fi
3. Apply it once from the pipeline with a lock timeout. Any SQL client works; for PostgreSQL, psql with ON_ERROR_STOP makes failures fail the job.
# Shell · deploy job · migration login · PostgreSQL
# WARNING: the lock_timeout applies to this session only.
PGOPTIONS="-c lock_timeout=3s" psql "$MIGRATION_URL" -v ON_ERROR_STOP=1 -f artifacts/migrate.sql
4. Handle non-transactional statements outside the script. If a migration uses CREATE INDEX CONCURRENTLY, either apply that migration with a bundle or database update (which honours suppressTransaction), or keep such statements out of EF migrations and run them as a separate, idempotent pipeline step (CREATE INDEX CONCURRENTLY IF NOT EXISTS ...) — the approach described in running concurrent index builds outside migration transactions. Test the idempotent script against a copy of production before relying on it.
Permanent Fix / Long-Term Pattern
Make the idempotent script the contract between development and operations. CI generates it on every build, uploads it as an artefact, and fails the build when the script contains unreviewed destructive or blocking statements. Reviewers see the SQL diff for new migrations. The deploy job applies the artefact from the build being deployed — never a freshly generated one — so what runs in production is byte-for-byte what was reviewed, the same property described in blocking deploys on failed migration dry runs.
Add a rehearsal: before production, apply the same artefact to a database restored from a recent production snapshot and time it, per testing migrations against production-like snapshots. Then apply it a second time; an idempotent script must be a no-op on the second run. Keep migrations that need special execution — concurrent indexes, long backfills — out of the idempotent script and in dedicated steps, and choose EF Core migration bundles when you want EF’s own transaction handling with the same apply-anywhere property.
Verification Checklist
Frequently Asked Questions
Is the idempotent script slower than a normal script?
Marginally. Each already-applied migration costs one lookup in __EFMigrationsHistory, which is negligible even for hundreds of migrations. The DDL itself runs exactly as in a normal script.
Does --idempotent make individual statements idempotent?
No. It makes each migration run at most once by checking the history table. If a migration fails halfway and leaves partial changes without recording its ID, rerunning will attempt the whole migration again, so raw SQL inside migrations should still use guards such as IF NOT EXISTS.
Can I use the idempotent script with SQL Server’s online index options?
Yes. CREATE INDEX ... WITH (ONLINE = ON) can run inside a transaction on SQL Server editions that support online operations, so it works within the script’s conditional batches. Test on the same edition as production, since online index operations are not available on all editions.
Should I commit the generated script to the repository? It is usually better as a build artefact, regenerated from the migrations on each build. Committing it invites hand edits that diverge from the migration classes; if you do commit it, add a CI check that regenerating it produces no diff.