MigrationGuard.Testing
0.2.0
dotnet add package MigrationGuard.Testing --version 0.2.0
NuGet\Install-Package MigrationGuard.Testing -Version 0.2.0
<PackageReference Include="MigrationGuard.Testing" Version="0.2.0" />
<PackageVersion Include="MigrationGuard.Testing" Version="0.2.0" />
<PackageReference Include="MigrationGuard.Testing" />
paket add MigrationGuard.Testing --version 0.2.0
#r "nuget: MigrationGuard.Testing, 0.2.0"
#:package MigrationGuard.Testing@0.2.0
#addin nuget:?package=MigrationGuard.Testing&version=0.2.0
#tool nuget:?package=MigrationGuard.Testing&version=0.2.0
MigrationGuard
Catch dangerous EF Core migrations before they reach production. MigrationGuard reads your migrations and fails the build when one contains an operation that is risky on a live database.
MC0001 error: 20260716160227_DropCustomerBio: Drops column "Bio" from "Customers". During a rolling deploy, old app instances still read/write this column.
safe: Expand → migrate → contract: stop using the column and deploy first, then drop it in a later migration.
docs: https://github.com/Mefju08/Migration-Analyzer/blob/master/docs/rules/MC0001.md
It catches four families of problem:
- Rolling-deploy breakage — dropping or renaming a column while the previous app version is still serving traffic.
- Long locks — a plain
CREATE INDEX,SET NOT NULL, or a new constraint blocking reads/writes while it scans. - Table rewrites — changing a column type, or adding a stored generated / identity column.
- Irreversible data loss — a
Downthat throws away whatUpbackfilled.
It is the .NET equivalent of Ruby's strong_migrations,
which had no counterpart in the .NET ecosystem.
Quick start
1. Install into your existing test project:
dotnet add package MigrationGuard.Testing
2. Add one test:
using MigrationGuard.Core;
using MigrationGuard.Testing;
public class MigrationSafetyTests
{
[Fact]
public void Migrations_are_production_safe() =>
MigrationCheck.Assert<AppDbContext>(o => o.Provider = DbProvider.PostgreSql(version: 16));
}
3. Run it. The test fails with the full report as its message:
MigrationGuard found 5 issues in 4 of 19 migrations:
20260716160205_CreateCustomerEmailIndex
MC0003 error: Creates index "IX_Customers_Email" on "Customers". On PostgreSQL a plain index build holds table locks for the duration of the build.
safe: Build the index without holding a write lock: migrationBuilder.Sql("CREATE INDEX CONCURRENTLY ...", suppressTransaction: true). CONCURRENTLY cannot run inside a transaction.
docs: https://github.com/Mefju08/Migration-Analyzer/blob/v0.2.0/docs/rules/MC0003.md
20260716160227_DropCustomerBio
MC0001 error: Drops column "Bio" from "Customers". During a rolling deploy, old app instances still read/write this column.
safe: Expand → migrate → contract: stop using the column and deploy first, then drop it in a later migration.
docs: https://github.com/Mefju08/Migration-Analyzer/blob/v0.2.0/docs/rules/MC0001.md
…
Summary: 5 error(s), 0 warning(s), 0 info.
note: a reviewed-and-accepted operation can be acknowledged with [SafeToRun("MC0003", "why it is safe here")] on the migration class.
(abridged — that is real output from this repo's fixture migrations)
No database is involved. MigrationGuard reads EF Core's own operation model, so the test is as fast as any unit test and runs anywhere.
Why
MigrationCheckand notMigrationGuard? A type namedMigrationGuardcannot coexist with the library'sMigrationGuard.*namespaces — the compiler rejects it withCS0434. See ADR-003.
A check fired. Now what?
You have four options, roughly in order of preference.
1. Fix it — follow the safe: line
Every diagnostic carries the safe alternative for that exact operation, and the docs: link explains
what the database does and why. For MC0003 on PostgreSQL:
// instead of migrationBuilder.CreateIndex(...)
migrationBuilder.Sql(
"CREATE INDEX CONCURRENTLY IX_Customers_Email ON \"Customers\" (\"Email\")",
suppressTransaction: true);
2. Suppress one rule on one migration — [SafeToRun]
When you have checked and it genuinely is safe. The reason is required, so the decision stays auditable
in review and in git blame:
using MigrationGuard.Annotations;
[SafeToRun("MC0003", "table has 200 rows, verified 2026-07-12")]
public partial class AddUserIndex : Migration { }
This attribute goes on the migration, so the project that owns your migrations needs
dotnet add package MigrationGuard.Annotations. It is netstandard2.0 with zero dependencies — that is the whole reason it ships separately from the engine.
3. Ignore everything that already shipped — baseline
See Adopting on an existing project below.
4. Tune the rule globally — severity
Downgrade a rule so it reports without failing the build, or turn it off completely:
MigrationCheck.Assert<AppDbContext>(o =>
{
o.Provider = DbProvider.PostgreSql(16);
o.Severity("MC0009", Severity.Warning); // report, don't fail
o.Disable("MC0017"); // off entirely
});
Only Error fails the build. Warning and Info are advisory — to see them, use the CLI or the
non-throwing MigrationCheck.Analyze<AppDbContext>(), which returns the AnalysisResult instead.
Adopting on an existing project
A repo with existing migrations will light up on the first run — those migrations already shipped, and rewriting history helps nobody. Baseline them: list their ids in a JSON file and MigrationGuard skips them entirely, so the tool only judges what you write from now on.
["20240101120000_InitialCreate", "20240115090000_AddOrders"]
A migration id is simply its file name without .cs. Generate the file with either built-in generator
— no scripting required:
// one-off (e.g. in a temporary test) — writes every current migration id, returns the count
MigrationCheck.WriteBaseline<AppDbContext>("migrationguard.baseline.json");
dotnet migration-check --assembly bin/Debug/net8.0/MyApp.dll --context AppDbContext \
--write-baseline migrationguard.baseline.json
Baselined migrations stay visible: the report's summary counts them (… 12 baselined.), so a run
that skipped half the history cannot read as a full clean bill of health.
Then point at it, and make sure the file reaches the test's output directory:
MigrationCheck.Assert<AppDbContext>(o =>
{
o.Provider = DbProvider.PostgreSql(16);
o.Baseline = "migrationguard.baseline.json";
});
<None Update="migrationguard.baseline.json" CopyToOutputDirectory="PreserveNewest" />
A relative path is resolved against the working directory first, then against the context assembly's
directory. If it resolves to nothing you get a FileNotFoundException — a configuration error, not a
finding.
Command-line tool
For pipelines that analyze a compiled assembly rather than running your test suite:
dotnet tool install --global MigrationGuard.Tool
dotnet migration-check \
--assembly src/MyApp/bin/Release/net8.0/MyApp.dll \
--context AppDbContext \
--config migrationguard.json
| Option | Required | Meaning |
|---|---|---|
--assembly |
yes | Path to the compiled assembly containing the migrations |
--context |
yes | DbContext name — simple (AppDbContext) or full |
--config |
no | Path to migrationguard.json; without it the provider is unknown and only provider-agnostic rules run |
--format |
no | text (default) or sarif |
--write-baseline |
no | Write every discovered migration id to this file and exit — adoption in an existing project |
Real output — findings grouped per migration, [SafeToRun] acknowledgements kept visible for audit:
MigrationGuard found 3 issues in 2 of 19 migrations:
20260716160227_DropCustomerBio
MC0001 error: Drops column "Bio" from "Customers". …
safe: Expand → migrate → contract: stop using the column and deploy first, then drop it in a later migration.
docs: https://github.com/Mefju08/Migration-Analyzer/blob/v0.2.0/docs/rules/MC0001.md
20260717063332_AddOrderSummaryWithBackfill
MC0007 warning: Mixes a data change (UPDATE/DELETE) with schema changes …
MC0010 info: Raw SQL that MigrationGuard cannot analyze — review it manually …
acknowledged by [SafeToRun] (1):
MC0003 20260716160326_CreateOrderTotalIndexSafe — Orders is small here; a blocking index build is acceptable.
Summary: 1 error(s), 1 warning(s), 1 info, 0 not analyzed, 1 acknowledged.
note: a reviewed-and-accepted operation can be acknowledged with [SafeToRun("MC0001", "why it is safe here")] on the migration class.
Docs links point at this build's own version tag, so they stay valid even after files move on master.
When the analysis runs without a configured provider, the report says so in a note: — provider-specific
rules are inactive in that mode, and silence would read as a clean bill of health.
Exit codes: 0 clean · 1 violations at Error severity · 2 tool failure — bad arguments, the
assembly or context could not be loaded, or a migration could not be analyzed. Exit 2 is deliberate:
an unanalyzable migration must never be reported as clean (ADR-002).
Point --assembly at the application's build output (the startup project's bin/, or dotnet publish output) — the tool resolves your EF provider and other dependencies from that directory.
The most common mistake is pointing at the migrations class library's own bin/: a class
library's build output has no NuGet dependencies in it, so the run fails with
Could not load file or assembly '<some dependency>'. Either aim at the app's output (the
migrations dll is copied there too), or make the class library self-contained by adding
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies> to its .csproj. The tool
detects this case and prints the same guidance as a hint: line.
Continuous integration
Fail the pull request:
- run: dotnet test # if you use the Testing package, you are already done
Or run the CLI and get inline PR annotations via SARIF:
jobs:
migration-safety:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write # required to upload SARIF
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
- run: dotnet build src/MyApp/MyApp.csproj -c Release
- run: dotnet tool install --global MigrationGuard.Tool
- name: Analyze migrations
id: check
continue-on-error: true # let the upload run, then fail explicitly below
run: |
dotnet migration-check \
--assembly src/MyApp/bin/Release/net8.0/MyApp.dll \
--context AppDbContext \
--config migrationguard.json \
--format sarif > migrationguard.sarif
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: migrationguard.sarif
- name: Fail on violations
if: steps.check.outcome == 'failure'
run: exit 1
SARIF maps Error → error, Warning → warning, Info → note, and each result links back to that
rule's documentation page. Note that a tool failure (exit 2) writes the reason to stderr and
produces no SARIF, so check the step log when the upload has nothing to read.
Configuration
Everything is available both in code and in migrationguard.json:
{
"provider": "postgresql",
"providerVersion": 16,
"largeTables": ["users", "events"],
"severity": { "MC0009": "warning", "MC0017": "none" },
"baseline": "migrationguard.baseline.json"
}
| Key | Code equivalent | Notes |
|---|---|---|
provider |
o.Provider = DbProvider.PostgreSql(16) |
postgresql | sqlserver; anything else = unknown provider |
providerVersion |
argument to DbProvider.* |
Drives version-dependent rules; defaults to 16 / 2022 |
largeTables |
o.LargeTables = ["users"] |
Enables MC0008 and MC0011, which only matter at size |
severity |
o.Severity(id, Severity.Warning) |
error | warning | info | none |
baseline |
o.Baseline = "..." |
JSON array of migration ids to skip |
| — | o.MigrationsAssembly = typeof(SomeMigration).Assembly |
When migrations live outside the context's assembly (MigrationsAssembly(...)) |
| — | o.AllowNoMigrations = true |
Permit a run that finds zero migrations (default: that throws — see below) |
Zero migrations found = error, not "clean". With migrations generated into a separate project,
the default lookup (the context's own assembly) finds nothing — and a green test that analyzed
nothing would be a false sense of safety. Both the Testing package and the CLI fail loudly in that
case; set o.MigrationsAssembly to point at the right assembly, or o.AllowNoMigrations = true for
a project that genuinely has no migrations yet.
none disables a rule completely — the same convention .editorconfig uses for diagnostics; in code
that is o.Disable("MC0017").
largeTables matters. Two rules stay silent until you declare which tables are big, because on a
small table their operations are harmless: MC0011 (adding a NOT NULL column with a default) and MC0008
(no lock_timeout). List the tables where a full rewrite or a lock queue would actually hurt.
Rule catalog
| Rule | Default severity | What it catches |
|---|---|---|
| MC0001 | Error | Dropping a column/table (breaks old instances during rolling deploy) |
| MC0002 | Error | Renaming a column/table (drop+add for the running old version) |
| MC0003 | Error (PG) / Warning (SQL Server) | Creating an index (blocking build) |
| MC0004 | Error (PG) / Warning (SQL Server) | Making a column NOT NULL (blocking scan / Sch-M lock) |
| MC0005 | Error (PG) | Changing a column type (table rewrite) |
| MC0006 | Warning | Adding a foreign key (validation under lock) |
| MC0007 | Warning | Data change (UPDATE/DELETE) inside a migration |
| MC0008 | Info | No lock_timeout on a large table |
| MC0009 | Warning | Down would destroy data that Up backfilled |
| MC0010 | Info | Raw SQL that cannot be analyzed |
| MC0011 | Warning | Adding a NOT NULL column with a default to a large table |
| MC0012 | Error | Adding a check constraint (validation under lock) |
| MC0013 | Error (PG) | Adding a stored generated column (table rewrite) |
| MC0014 | Error (PG) | Adding an identity/auto-incrementing column (table rewrite) |
| MC0015 | Error (PG) | Adding a unique constraint (ACCESS EXCLUSIVE) |
| MC0016 | Warning (PG) | Adding a json column (docs recommend jsonb) |
| MC0017 | Info | Non-unique index over more than three columns |
| MC0018 | Error (PG) | Dropping an index (ACCESS EXCLUSIVE; also fires on the implicit drop when EF replaces an index) |
Each rule's page states what EF generates, why it hurts, the safe alternative with code, and links to the official documentation the behavior came from.
Providers
- PostgreSQL — fully modeled (all 18 rules).
- SQL Server — modeled from verified Microsoft documentation: the Sch-M lock taken by ALTER TABLE,
WITH CHECK validation of new constraints (safe path:
WITH NOCHECK+ re-check),ONLINE = ON(2016+), and the 2012+/Enterprise metadata-only fast path for constant defaults. MC0003/MC0004 are Warning rather than Error where the docs leave nuance; MC0005 and MC0013–MC0016 stay silent because their facts are not verified for SQL Server. - Unknown / other — only provider-agnostic rules (MC0001, MC0002, MC0007, MC0009, MC0010) run.
MigrationGuard never claims something is safe on an unverified fact. Where a database behavior could
not be confirmed from official documentation it is marked ⚠️ TODO in the rule page and the rule stays
at Warning rather than Error. Both providers are covered end-to-end by real dotnet ef-generated
fixture migrations.
Writing your own rule
IMigrationRule is public, and so is the engine — a rule is a pure function from a migration's
operations to diagnostics:
using Microsoft.EntityFrameworkCore.Migrations.Operations;
using MigrationGuard.Core;
public sealed class NoTruncateRule : IMigrationRule
{
public string Id => "APP0001";
public IEnumerable<Diagnostic> Analyze(MigrationInfo migration, AnalysisContext ctx)
{
foreach (MigrationOperation op in migration.UpOperations)
{
if (op is SqlOperation sql && sql.Sql.Contains("TRUNCATE", StringComparison.OrdinalIgnoreCase))
yield return new Diagnostic(
Id, Severity.Error, migration.Id,
"TRUNCATE in a migration empties the table with no way back.",
"Delete in batches from a maintenance job instead.");
}
}
}
MigrationCheck.Assert always uses the built-in rules, so to add your own, compose the engine directly
— every piece of it is public API:
ProviderProfile provider = DbProvider.PostgreSql(16);
IReadOnlyList<MigrationInfo> migrations = MigrationLoader.Load(
typeof(AppDbContext).Assembly, typeof(AppDbContext), provider.ActiveProvider);
var analyzer = new MigrationAnalyzer([.. MigrationAnalyzer.DefaultRules(), new NoTruncateRule()]);
AnalysisResult result = analyzer.Analyze(migrations, new AnalysisContext(provider));
Assert.False(result.HasErrors, DiagnosticFormatter.FormatReport(result.Diagnostics));
[SafeToRun("APP0001", "…")] and severity overrides work on your rules exactly as on the built-in ones —
the engine keys both off the rule id.
How it works
MigrationGuard analyzes the EF Core semantic model — Migration.UpOperations, the same operation
list EF itself turns into SQL. Not source code, not SQL text:
var migration = (Migration)Activator.CreateInstance(migrationType)!;
migration.ActiveProvider = "Npgsql.EntityFrameworkCore.PostgreSQL";
IReadOnlyList<MigrationOperation> ops = migration.UpOperations; // ← rules pattern-match on these
No Roslyn, no SQL parsing, no database connection. The rationale, and what that buys and costs, is in ADR-001; the CLI's isolated assembly loading is in ADR-002.
The one thing it cannot read is raw migrationBuilder.Sql(...) — that is reported as MC0010 so you know
the migration was not fully checked, rather than being silently passed.
Parity with strong_migrations
Benchmarked against strong_migrations' 24 checks. Six
cannot apply to EF Core (force: true is an ActiveRecord concept; exclusion constraints and enum renames
have no EF operation — they surface as raw SQL via MC0010; and 3 are MySQL/MariaDB-only). Of the 18 that
do apply, 17 are fully covered and one partially: renaming a schema is caught only when it arrives
as a RenameTable with a new schema, because a standalone schema rename has no EF Core operation.
Feature parity: safety_assured → [SafeToRun] · start_after → baseline · target_version →
providerVersion · custom checks → IMigrationRule · disabling a check → o.Disable("MC0017").
Deliberately out of scope: strong_migrations' "safe by default" auto-rewriting and its lock/statement
timeout and retry settings — those belong to a migration runner. MigrationGuard is an analyzer: MC0008
advises on lock_timeout, it does not execute anything.
Packages
| Package | Install it in | Purpose |
|---|---|---|
MigrationGuard.Testing |
your test project | MigrationCheck.Assert<TContext>() |
MigrationGuard.Annotations |
the project owning your migrations | [SafeToRun] (netstandard2.0, zero deps) |
MigrationGuard.Tool |
global tool | dotnet migration-check |
MigrationGuard.Core |
— | Engine, rules, provider profiles; comes with the others |
Building
dotnet build MigrationGuard.sln
dotnet test MigrationGuard.sln
Requires the .NET 8 SDK. CI (build + test + pack) runs in GitHub Actions. Design decisions live in docs/adr, and a running decision log in docs/NOTES.md.
License
MIT.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net8.0
- MigrationGuard.Core (>= 0.2.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.