Skip to content

Pragmatic.Migrations

Declarative schema migrations for Pragmatic.Design — no migration files, no EF Core migrations, no manual SQL.

Compile Time Runtime
┌──────────────┐ ┌─────────────────────────────────────────┐
│ Source Gen │ │ Introspect DB → Diff → SQL → Execute │
│ ↓ │ │ │
│ SchemaVersion │────▶│ SchemaDiffEngine → SqlGenerator → Runner│
│ (desired) │ │ │
└──────────────┘ └─────────────────────────────────────────┘
  1. Source Generator reads [Entity] attributes and emits a SchemaVersion (desired schema) at compile time
  2. Introspector reads the current database schema via information_schema / sys.* / PRAGMA
  3. Diff Engine compares desired vs current, producing ordered SchemaChange[]
  4. SQL Generator emits idempotent, provider-specific SQL (IF NOT EXISTS, DO $$ blocks)
  5. Runner executes each change in a transaction — if one fails, everything rolls back
// Program.cs — zero configuration
PragmaticApp.RunAsync(builder =>
{
builder.UsePragmaticMigrations();
});

That’s it. The SG detects Pragmatic.Migrations in your references and auto-generates the schema.

ProviderGeneratorIntrospectorIdempotent SQL
PostgreSQLDO $$ IF NOT EXISTS $$ blocksinformation_schema + pg_catalogYes
SQL ServerIF NOT EXISTS (sys.*) checkssys.* viewsYes
SQLiteCREATE IF NOT EXISTSsqlite_master + PRAGMAPartial

Provider is auto-detected from SchemaVersion.ProviderName (set by the SG based on your EF Core provider).

builder.UsePragmaticMigrations(m =>
{
m.OnlyDatabase<ShowcaseAppDatabase>(); // Migrate only this DB
});
builder.UsePragmaticMigrations(m =>
{
m.DryRun(); // Generate SQL without executing
});

By default, breaking changes (DROP TABLE, DROP COLUMN, SET NOT NULL, narrowing type changes) are blocked. Use Force to apply:

builder.UsePragmaticMigrations(m =>
{
m.Force(); // Allow breaking changes at startup
});

At host startup a blocked breaking change — or any migration failure — aborts startup: the host will not run on a stale or partial schema. Inspect the failure, then either revise the entity change or opt in with m.Force() for a controlled deployment.

IDataMigration runs a named data transformation — backfilling a column, converting values, moving data between tables — exactly once per database. Each is tracked by name in __PragmaticDataMigrations and runs in its own transaction after the schema changes.

public sealed class BackfillOrderStatus : IDataMigration
{
public string Name => "2026-05_BackfillOrderStatus"; // stable, immutable — renaming re-runs it
public int Order => 0;
public async Task MigrateAsync(DbConnection connection, DbTransaction transaction, CancellationToken ct)
{
var cmd = connection.CreateCommand();
cmd.Transaction = transaction; // required — keeps the change atomic with the tracking record
cmd.CommandText = "UPDATE \"Orders\" SET \"Status\" = 'pending' WHERE \"Status\" IS NULL";
await cmd.ExecuteNonQueryAsync(ct);
}
}
builder.UsePragmaticMigrations(m => m.AddDataMigration<BackfillOrderStatus>());

Tables owned by another system, a DBA, or a database extension can be kept out of migration management — the diff engine will never propose to alter or drop them:

builder.UsePragmaticMigrations(m => m.ExcludeTable("LegacyAudit", "spatial_ref_sys"));

For deployments where new indexes would lock large tables, opt into post-commit out-of-transaction index creation. PostgreSQL emits CREATE INDEX CONCURRENTLY, SQL Server emits WITH (ONLINE = ON) (Enterprise edition), SQLite is unaffected (no concurrent mode — the flag is a no-op):

builder.UsePragmaticMigrations(m => m.UseConcurrentIndexes());

The runner defers every AddIndex change to a phase that runs after the schema transaction commits. A concurrent build that fails leaves the index INVALID on the server (the schema is already committed) and the migration is reported as failed with a suggestion to drop and recreate the index after fixing the underlying cause.

When a migration fails, the result includes:

  • Which change failed (index + description)
  • The SQL that failed (not the entire batch)
  • Context-aware suggestions (e.g., “Column may contain NULL values — update before applying SET NOT NULL”)
  • Transaction safety — all changes are rolled back, database is in its original state
Migration failed at step 3/7: SET NOT NULL Orders.Email
Error: column "Email" of relation "Orders" contains null values
Suggestions:
- Run with DryRun=true to inspect the generated SQL before applying
- SET NOT NULL failed — the column may contain NULL values
- Update existing NULL values before applying this change
- All changes have been rolled back — the database is in its original state

Every successful migration is recorded in __PragmaticSchema:

ColumnDescription
SchemaHashFNV-1a hash of the desired schema
MigrationSqlThe SQL that was executed
ChangesAppliedNumber of changes
DurationMsExecution time
AppliedAtTimestamp

The desired schema can be written to committed JSON files — one schema/<database>.schema.json per database — so schema changes are reviewable in pull requests and multi-branch schema conflicts surface as ordinary merge conflicts.

Generate it with the CLI:

Terminal window
pragmatic-migrate snapshot --assembly bin/Debug/net10.0/MyApp.dll --output schema

Or regenerate it automatically after every host build — opt in from the host project and the Pragmatic.Migrations MSBuild target does the rest:

<PropertyGroup>
<PragmaticSchemaSnapshot>true</PragmaticSchemaSnapshot>
</PropertyGroup>

Commit the schema/ folder. In CI, regenerate the snapshot and fail the build when it drifted from the entities:

Terminal window
pragmatic-migrate snapshot --assembly path/to/MyApp.dll --output schema
git diff --exit-code schema/

A non-empty diff means an entity changed without the snapshot being regenerated — or that two branches changed the schema in conflicting ways.

The pragmatic-migrate CLI operates on a compiled host assembly. Beyond apply, status, script, snapshot, and history, two commands support client and manifest workflows:

CommandDescription
manifestExports the manifest JSON embedded in a compiled assembly to a file (--assembly, --output).
generateGenerates a typed client project from manifest data — C# (default) or TypeScript via --language (--assembly or --manifest, --output, optional --namespace/--boundary).
Terminal window
# Export the manifest
pragmatic-migrate manifest --assembly bin/Debug/net10.0/MyApp.dll --output manifest.json
# Generate a typed C# client (use --language typescript for TS)
pragmatic-migrate generate --assembly bin/Debug/net10.0/MyApp.dll --output ./Client/

ITenantMigrationOrchestrator (default TenantMigrationOrchestrator) applies the desired schema across every active tenant that has a dedicated database. It iterates the ITenantStore, runs the migration runner per tenant, marks each tenant Migrating/Active/Suspended, and reports progress to the control plane (IHostStatus/IControlPlane). Use MigrateAllTenantsAsync for the full sweep or MigrateTenantAsync(tenantId, ...) for a single tenant; shared-database tenants are skipped.

IMigrationHook runs custom logic around individual schema changes — for inline data transformation (populating new NOT NULL columns, converting types, moving data) tied to the change’s transaction:

  • DatabaseName — filter to a single database, or null for all.
  • BeforeChangeAsync(context, ct) — return false to skip the change (default: proceed).
  • AfterChangeAsync(context, ct) — runs after the change commits successfully; create commands via MigrationStepContext.Connection and set their Transaction to MigrationStepContext.Transaction so the data migration is atomic with the schema change.
FeatureEF Core MigrationsPragmatic.Migrations
Migration filesYes (per-change)No (declarative)
Design-time toolsRequired (dotnet ef)Not needed
ModelSnapshotRequiredNot needed (SG-generated)
Idempotent SQLOptional (--idempotent)Always
Multi-databaseManualAuto-detected from topology
Transaction safetyProvider-dependentAlways (per-change execution)
Error diagnosticsGenericChange-level with suggestions
Breaking change detectionNoYes (blocks without Force)
Pragmatic.Migrations/
├── Schema/ # SchemaVersion, TableSchema, ColumnSchema, IndexSchema, ForeignKeySchema
├── Diff/ # SchemaDiffEngine, SchemaDiff
│ └── Changes/ # 12 SchemaChange subtypes (CreateTable, AddColumn, RenameColumn, etc.)
├── Introspection/ # ISchemaIntrospector (3 providers), IConnectionFactory
├── Sql/ # ISqlMigrationGenerator, SqlGeneratorBase (3 providers)
├── Runner/ # IMigrationRunner, MigrationRunner, MigrationResult
└── Configuration/ # MigrationsBuilder, MigrationProviderFactory

Samples:

Part of the Pragmatic.Design ecosystem — see Licensing. Pragmatic.Migrations is licensed under the PolyForm Small Business 1.0.0 license (free for small businesses; commercial license above the threshold).