Skip to content

Concepts — Pragmatic.Migrations Architecture

Deep dive into how declarative schema migrations work: from compile-time metadata to runtime execution.


Pragmatic.Migrations eliminates migration files entirely. Instead of an imperative sequence of “add this column, drop that table”, you declare the desired state via [Entity] attributes and the framework computes what needs to change.

┌──────────────────────┐ ┌──────────────────────────────────────────────────┐
│ Compile Time │ │ Runtime │
│ │ │ │
│ Source Generator │ │ Introspect DB │
│ reads [Entity] │────▶│ ↓ │
│ ↓ │ │ SchemaDiffEngine.ComputeDiff(desired, current) │
│ SchemaVersion │ │ ↓ │
│ (desired schema) │ │ ISqlMigrationGenerator.GenerateScript(diff) │
│ │ │ ↓ │
│ │ │ MigrationRunner.MigrateAsync(context) │
└──────────────────────┘ └──────────────────────────────────────────────────┘

The Source Generator produces a SchemaVersion record for each database in your topology:

public sealed record SchemaVersion(
string Hash, // FNV-1a content hash
ImmutableArray<TableSchema> Tables, // All tables
string? DatabaseName, // e.g. "ShowcaseAppDatabase"
string? ProviderName, // e.g. "PostgreSql"
string? ConfigKey); // e.g. "ConnectionStrings:App"

Each TableSchema contains:

public sealed record TableSchema(
string TableName,
ImmutableArray<ColumnSchema> Columns,
ImmutableArray<IndexSchema> Indexes,
ImmutableArray<ForeignKeySchema> ForeignKeys);

And each column carries full type information:

public sealed record ColumnSchema(
string ColumnName,
string ClrType,
string? ProviderType, // e.g. "varchar(256)", "timestamptz"
bool IsNullable,
bool IsPrimaryKey,
string? DefaultValue,
int? MaxLength);

The Hash field is an FNV-1a hash computed over all tables, columns, indexes, and foreign keys. This enables a fast path: if the introspected schema produces the same hash, the diff engine is skipped entirely.

Current hash == Desired hash → MigrationResult.NoChanges (no diff needed)

Each provider implements ISchemaIntrospector to read the current database schema:

ProviderStrategySystem Tables
PostgreSQLinformation_schema + pg_cataloginformation_schema.tables, information_schema.columns, pg_indexes
SQL Serversys.* viewssys.tables, sys.columns, sys.indexes, sys.foreign_keys
SQLitesqlite_master + PRAGMAsqlite_master, PRAGMA table_info, PRAGMA index_list

The introspector returns a SchemaVersion representing the current state. Framework-internal tables (__PragmaticSchema, __EFMigrationsHistory) are excluded automatically.

You never specify the provider manually. The SG reads which EF Core provider package is referenced and sets SchemaVersion.ProviderName accordingly. At runtime, MigrationProviderFactory resolves the correct introspector, SQL generator, and connection factory via keyed DI services.


The ISchemaDiffEngine compares two SchemaVersion records and produces a SchemaDiff:

public sealed record SchemaDiff(
ImmutableArray<SchemaChange> Changes,
bool HasBreakingChanges,
SchemaVersion? DesiredSchema);

The diff engine produces an ordered list of SchemaChange subtypes:

Change TypeDescriptionBreaking?
CreateTableNew table with all columns, indexes, FKsNo
DropTableTable removed from desired schemaYes
AddColumnNew column on existing tableNo
DropColumnColumn removed from desired schemaYes
RenameColumnColumn name changedNo
AlterColumnTypeColumn type changed (e.g., int to bigint)Depends
AlterColumnNullabilityNULL to NOT NULL or vice versaNOT NULL = risky
AddIndexNew indexNo
DropIndexIndex removedNo
AddForeignKeyNew FK constraintNo
DropForeignKeyFK removedNo
AlterColumnDefaultDefault value changedNo

Changes are emitted in dependency order:

  1. CreateTable (no FK references first)
  2. AddColumn
  3. AlterColumn*
  4. AddIndex
  5. AddForeignKey (after both tables exist)
  6. DropForeignKey (before dropping tables)
  7. DropIndex
  8. DropColumn
  9. DropTable

This ordering ensures referential integrity is maintained throughout the migration.

A change is marked IsBreaking = true when it may cause data loss:

  • DropTable — entire table and data removed
  • DropColumn — column data lost
  • AlterColumnNullability with NewIsNullable = false — may fail if NULLs exist
  • AlterColumnType — narrowing conversions may truncate data

Breaking changes are blocked by default. The runner returns a failed MigrationResult with suggestions. Use Force = true to override.

At host startup, a failed MigrationResult aborts startup (fail-fast): the host never runs on a stale or partial schema. Configure the runner with UsePragmaticMigrations(m => m.Force()) to opt into breaking changes, or m.DryRun() to log the plan without applying it.


Each provider implements idempotent SQL generation:

PostgreSQL — Uses DO $$ ... $$ blocks:

DO $$ BEGIN
IF NOT EXISTS (SELECT FROM pg_tables WHERE tablename = 'Reservations') THEN
CREATE TABLE "Reservations" (
"Id" uuid NOT NULL PRIMARY KEY,
"GuestName" text NOT NULL,
"CheckIn" date NOT NULL
);
END IF;
END $$;

SQL Server — Uses IF NOT EXISTS (sys.*) checks:

IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'Reservations')
CREATE TABLE Reservations (
Id uniqueidentifier NOT NULL PRIMARY KEY,
GuestName nvarchar(max) NOT NULL,
CheckIn date NOT NULL
);

SQLite — Uses CREATE IF NOT EXISTS (partial idempotency):

CREATE TABLE IF NOT EXISTS Reservations (
Id TEXT NOT NULL PRIMARY KEY,
GuestName TEXT NOT NULL,
CheckIn TEXT NOT NULL
);

SQLite has limited ALTER TABLE support. For column type changes, the generator uses the table-rebuild strategy (CREATE new, INSERT SELECT, DROP old, RENAME).

The runner does not execute the full script as a single batch. Instead, GenerateChangeScript(change) produces the SQL for each individual SchemaChange. This enables:

  • Precise error reporting (“failed at step 3/7”)
  • Per-change progress tracking
  • Context-aware suggestions based on the failed change type

MigrationRunner.MigrateAsync(context) executes this pipeline:

Phase 0: Open connection + acquire advisory lock (MigrationLock)
Phase 1: Introspect current schema
→ Fast path: if hashes match, skip to Phase 7
Phase 2: Compute diff (SchemaDiffEngine)
Phase 3: Generate SQL + safety check (breaking changes)
→ Dry run: return result without executing
Phase 4: Execute each change in a transaction
→ On failure: rollback + build error suggestions
Phase 5: Record in __PragmaticSchema audit table
Phase 6: Run seed providers (IMigrationSeedProvider)
Phase 7: Run data migrations (IDataMigration) in a dedicated transaction

IDataMigration is a named, versioned data transformation — backfilling a column, converting values, moving data between tables. Each runs exactly once per database, tracked by Name in the __PragmaticDataMigrations table, inside a dedicated transaction after the schema phase. Data migrations run even when the schema diff is empty (the fast path skips straight to Phase 7), so a data-only change still applies. Implementations must enrol their commands in the supplied DbTransaction so the change is atomic with the tracking record. If a data migration throws, its transaction is rolled back and the migration result is failed.

All schema changes are executed inside a single database transaction. If any change fails:

  1. The transaction is rolled back
  2. The MigrationResult includes FailedChangeIndex, FailedChangeSql, and context-aware Suggestions
  3. The database is left in its original state

Before introspection begins, the runner acquires an advisory lock via MigrationLock. This prevents concurrent migrations within the same process (e.g., parallel host starts in tests). The lock is released after the transaction completes.

This is separate from leader election, which prevents concurrent migrations across processes/instances.

The runner reports progress via IMigrationProgressStream. Each event includes:

  • Phase — “analyzing”, “applying”, “complete”, “error”
  • Message — Human-readable description
  • ProgressPercent — 0-100 during execution
  • DatabaseName — Which database is being migrated
  • IsError / ErrorDetail — On failure

This integrates with the Control Plane: the host reports migration progress via heartbeats, visible on the hub.


When multiple instances of the same host start simultaneously (e.g., Kubernetes rolling deploy), only one should run migrations. Without coordination, you get race conditions: duplicate table creation, conflicting ALTER statements, or deadlocks.

The default strategy uses the database itself for coordination — no external infrastructure (Redis, Consul, etcd) required.

It creates a __PragmaticLock table with a single row:

ColumnTypeDescription
LockNamevarchar(128)Always 'migration'
HolderIdvarchar(64)Guid7-based host identifier
AcquiredAttimestamptzWhen the lock was acquired
ExpiresAttimestamptzAuto-expiry (default: 5 minutes)

The acquisition query is atomic:

UPDATE "__PragmaticLock"
SET "HolderId" = @holderId,
"AcquiredAt" = NOW(),
"ExpiresAt" = NOW() + MAKE_INTERVAL(mins => @timeoutMinutes)
WHERE "LockName" = 'migration'
AND ("HolderId" IS NULL OR "HolderId" = @holderId OR "ExpiresAt" < NOW())

This handles three cases:

  1. No holder (HolderId IS NULL) — first instance wins
  2. Same holder (HolderId = @holderId) — re-entrant, same instance retries
  3. Expired (ExpiresAt < NOW()) — previous leader crashed, new one takes over

Wraps DatabaseLeaderElection with a safety net. If the inner election throws (e.g., database not reachable for the lock table), it logs a warning and proceeds as leader. This ensures a single-instance deployment never fails to start.

Used when no connection factory is available (e.g., test/mock scenarios). Always grants leadership.

Non-leader instances call WaitForLeaderCompletionAsync(), which polls every 2 seconds:

while (!ct.IsCancellationRequested)
{
await Task.Delay(2000, ct);
// Check if HolderId is NULL (leader released) or expired
var result = await cmd.ExecuteScalarAsync(ct);
if (result is null or DBNull)
return; // Leader completed, proceed
}

For multi-tenant deployments with database-per-tenant, the orchestrator iterates all active tenants with dedicated connection strings:

1. Get active tenants from ITenantStore
2. Filter to tenants with dedicated databases (non-null ConnectionString)
3. For each tenant:
a. Mark tenant as Migrating
b. Run MigrationRunner.MigrateAsync with tenant's connection string
c. On success: mark Active
d. On failure: mark Suspended
4. Report progress to IHostStatus and IControlPlane

The orchestrator integrates with:

  • IHostStatus — Reports HostState.Migrating with per-tenant progress
  • IControlPlane — Pushes status updates to the hub for cluster visibility
  • ITenantStore — Updates tenant state (Active/Migrating/Suspended)

Each tenant migration uses the standard MigrationRunner pipeline, including leader election per tenant database.


All log messages use [LoggerMessage] source-generated methods in MigrationDiagnostics:

EventLevelMessage
Migration startedInformation[{DatabaseName}] Starting migration ({ProviderName}) -- desired hash: {DesiredHash}
Schema up to dateInformation[{DatabaseName}] Schema up to date -- no changes needed (hash: {Hash})
Diff computedInformation[{DatabaseName}] Diff computed: {ChangeCount} changes ({BreakingCount} breaking)
Applying changeDebug[{DatabaseName}] Applying {StepIndex}/{TotalSteps}: {ChangeDescription}
Migration completeInformation[{DatabaseName}] Migration complete: {ChangeCount} changes applied in {DurationMs}ms
Migration failedError[{DatabaseName}] Migration failed at step {StepIndex}/{TotalSteps}: {ChangeDescription}
Breaking blockedWarning[{DatabaseName}] Blocked: {BreakingCount} breaking changes require Force=true

ActivitySource: Pragmatic.Migrations

Spans are created for each migration run with tags:

  • db.name — Database name
  • db.provider — Provider name
  • migrations.change_count — Number of changes
  • migrations.breaking_count — Number of breaking changes
  • migrations.duration_ms — Total duration
MetricTypeDescription
pragmatic.migrations.executedCounterTotal migrations executed
pragmatic.migrations.failedCounterFailed migrations
pragmatic.migrations.changes_appliedCounterSchema changes applied
pragmatic.migrations.durationHistogramMigration duration (ms)

AspectEF Core MigrationsPragmatic.Migrations
ApproachImperative (migration files)Declarative (desired state)
Migration filesRequired, one per changeNone
Design-time toolsdotnet ef requiredNot needed
ModelSnapshotRequired for diffingNot needed (SG-generated)
IdempotencyOptional (--idempotent flag)Always idempotent
Multi-databaseManual configurationAuto-detected from topology
Transaction safetyProvider-dependentAlways (single transaction)
Error reportingGeneric exceptionPer-change with suggestions
Breaking detectionNoneBuilt-in, blocks by default
Leader electionNot includedBuilt-in (database-based)
Tenant orchestrationManualBuilt-in via TenantMigrationOrchestrator
Audit trailNot included__PragmaticSchema table
Data migrationsHand-written SQL in migration filesFirst-class IDataMigration — named, tracked, run-once
Schema snapshotModelSnapshot.cs (diff input, drifts)Committed schema/*.json (review artifact, generated)

Pragmatic.Migrations/
├── Schema/ # Data model for desired/current schema
│ ├── SchemaVersion # Top-level: hash + tables + provider
│ ├── TableSchema # Table name + columns + indexes + FKs
│ ├── ColumnSchema # Column name, type, nullable, PK, default
│ ├── IndexSchema # Index name, columns, unique flag
│ └── ForeignKeySchema # FK name, columns, referenced table
├── Diff/ # Comparison engine
│ ├── SchemaDiffEngine # Compares desired vs current
│ ├── SchemaDiff # Result: ordered SchemaChange[]
│ └── Changes/ # 12 SchemaChange subtypes
├── Introspection/ # Database schema readers
│ ├── ISchemaIntrospector
│ ├── PostgreSqlSchemaIntrospector
│ ├── SqlServerSchemaIntrospector
│ └── SqliteSchemaIntrospector
├── Sql/ # SQL script generators
│ ├── ISqlMigrationGenerator
│ ├── SqlGeneratorBase
│ ├── PostgreSqlMigrationGenerator
│ ├── SqlServerMigrationGenerator
│ └── SqliteMigrationGenerator
├── Runner/ # Execution engine
│ ├── IMigrationRunner
│ ├── MigrationRunner # Main pipeline
│ ├── MigrationResult # Success/failure + suggestions
│ ├── MigrationOptions # DryRun, Force, Timeout, Filter
│ ├── MigrationContext # Connection string + schema + options
│ ├── MigrationLock # Advisory lock (intra-process)
│ ├── MigrationResilience # Retry on transient connection failures
│ ├── DatabaseLeaderElection # Cross-process via __PragmaticLock
│ ├── FallbackLeaderElection # Safety wrapper
│ └── MigrationDiagnostics # Logging + OTel + Metrics
├── Tenant/ # Multi-tenant orchestration
│ ├── ITenantMigrationOrchestrator
│ ├── TenantMigrationOrchestrator
│ ├── TenantMigrationOptions
│ └── TenantMigrationSummary
├── Configuration/ # DI and builder
│ ├── MigrationsBuilder
│ └── MigrationProviderFactory
└── Extensions/ # IPragmaticBuilder extensions
└── PragmaticBuilderMigrationsExtensions