pragmatic-use-migrations · git:20261012.bc22592 · 2026-10-12 · sha256 fd237e6b50a74508

pragmatic-use-migrations git:20261012.bc22592A

Immutable. This exact content never changes and is served at /api/v1/blob/fd237e6b50a74508.

---
name: pragmatic-use-migrations
description: Use when the schema must follow the entities, a change is breaking, or data needs a backfill (Pragmatic.Migrations, no migration files, data migrations, snapshots, DB-per-tenant, the pragmatic-migrate CLI).
---

# Pragmatic Use Migrations

**Covers:** Declarative schema migrations with Pragmatic.Migrations, no migration files: the runner diffs the schema generated from the entities against the database and applies it in one transaction, blocking data loss unless forced; data migrations, snapshots, DB-per-tenant, the pragmatic-migrate CLI.

`Pragmatic.Migrations` replaces EF Core Migrations. There are **no migration files** and no
design-time tools: the source generator emits the desired schema from your `[Entity]` types at
compile time, and at startup the runner introspects the live database, diffs it, and applies the
difference.

```
[Entity] classes ──SG──▶ {Db}Schema.Current ──▶ diff vs live DB ──▶ idempotent SQL ──▶ apply in one TX
```

## Packages

```xml
<PackageReference Include="Pragmatic.Migrations" Version="1.0.0-alpha.2" />
<!-- plus the driver, in the HOST project -->
<PackageReference Include="Npgsql" Version="9.*" />
```

The host must reference the database driver (`Npgsql` / `Microsoft.Data.SqlClient` /
`Microsoft.Data.Sqlite`), not only the module projects.

## Enable it

```csharp
await PragmaticApp.RunAsync(args, builder =>
{
    builder.UsePragmaticMigrations();
});
```

That is the whole setup. Every database in the topology is migrated at startup, before the app
serves traffic. **A failed migration aborts startup**: the host never runs on a stale schema.

## Configuration

```csharp
builder.UsePragmaticMigrations(m =>
{
    m.OnlyDatabase<AppDatabase>();       // migrate just this database
    m.DryRun();                          // compute and report the plan, execute nothing
    m.Force();                           // allow breaking changes (see below)
    m.ExcludeTable("LegacyAudit");       // tables owned by someone else
    m.ManageDeclaredTablesOnly();        // never DROP a table this host did not declare
    m.UseAuditTable("_MyHistory");       // rename __PragmaticSchema
    m.UseConcurrentIndexes();            // build indexes after commit, without locking the table
    m.AddDataMigration<BackfillStatus>();
    m.UseProvider(MigrationConstants.ProviderPostgreSql, cs => new NpgsqlConnection(cs));
});
```

## Breaking changes are blocked

A change that can lose data or fail against existing rows is **breaking**, and the migration refuses
it unless you opt in with `Force()`:

- `DROP TABLE`, `DROP COLUMN`
- `SET NOT NULL` (fails if any row holds NULL)
- a primary-key change
- `ADD COLUMN` that is NOT NULL with no default
- a narrowing type change: smaller capacity in the same family (`varchar(256)`→`varchar(50)`), or a
  change of family altogether (`varchar(50)`→`int`)

The result carries `FailedChangeIndex`, `FailedChangeSql` and actionable `Suggestions`. The intended
workflow is: `DryRun()` → review → `Force()` for a controlled deploy. Never `Force()`
unconditionally in production.

## Data migrations: backfilling data

`IDataMigration` runs **exactly once per database**, tracked by name in `__PragmaticDataMigrations`,
in its own transaction after the schema phase. It runs even when the schema did not change.

```csharp
public sealed class BackfillStatus : IDataMigration
{
    public string Name => "2026-05_BackfillStatus";   // stable; renaming makes it run again
    public int Order => 0;
    public string? DatabaseName => null;              // null = every database

    public async Task MigrateAsync(DbConnection connection, DbTransaction transaction, CancellationToken ct = default)
    {
        var cmd = connection.CreateCommand();
        cmd.Transaction = transaction;                 // REQUIRED: atomic with the tracking record
        cmd.CommandText = """UPDATE "Orders" SET "Status" = 'pending' WHERE "Status" IS NULL""";
        await cmd.ExecuteNonQueryAsync(ct);
    }
}
```

Register with `m.AddDataMigration<BackfillStatus>()`.

Two neighbours, easy to confuse:

| Use | When |
|-----|------|
| `IDataMigration` | A one-time transformation. Runs once per database, after the schema. |
| `IMigrationSeedProvider` | Idempotent reference data (`ON CONFLICT DO NOTHING`). Runs after every migration that applied changes. |
| `IMigrationHook` | Logic tied to one specific change, inside the migration transaction (`BeforeChangeAsync` can skip it; `AfterChangeAsync` populates a column right after it is added). |

## Schema snapshot: reviewing schema changes in a PR

Commit a generated JSON snapshot so schema changes show up in code review and branch conflicts
become ordinary merge conflicts.

```xml
<PropertyGroup>
  <PragmaticSchemaSnapshot>true</PragmaticSchemaSnapshot>
</PropertyGroup>
```

CI gate:

```bash
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.

## CLI (optional)

```bash
dotnet tool install -g Pragmatic.Migrations.Cli

pragmatic-migrate status   --project src/MyApp.Host        # what would change
pragmatic-migrate script   --assembly bin/.../MyApp.dll    # print the SQL
pragmatic-migrate apply    --project src/MyApp.Host --verbose
pragmatic-migrate snapshot --project src/MyApp.Host --output schema
pragmatic-migrate history  --project src/MyApp.Host
```

Add `--audit-table <name>` when the host renamed the audit table with `UseAuditTable`: the CLI does
not build the host's DI container, so it has to be told.

`apply` shows the diff, prices the data impact of each breaking change and asks before applying it,
then delegates to the same runner the host uses. It applies **schema changes only**: data
migrations, hooks and seed providers live in the host's DI container.

Flags: `--assembly` / `--project` (builds it), `--connection`, `--config`, `--database`,
`--dry-run`, `--force`, `--yes` (non-interactive), `--json`, `--agent`, `--verbose`, `--no-color`.

## Multiple instances

Only one instance migrates. `DatabaseLeaderElection` claims a row in `__PragmaticLock`; the others
wait, then **verify the schema** before reporting success: a leader that crashed without migrating
does not let the followers boot on a stale database. If leader election itself fails, an instance
defaults to **not leader** (never assumes leadership: that would be split-brain). No extra
infrastructure. SQLite skips it entirely.

## DB-per-tenant

With `services.AddDbPerTenant(...)`, every active tenant holding a dedicated connection string is
migrated at startup, right after the host's own database. A tenant failure aborts startup. Tune with
`TenantMigrationOptions`: `MaxParallelism` (1), `ContinueOnFailure` (false), `SuspendOnFailure`
(true), `TenantTimeout` (10 min).

## Sharing a database with another system

By default a table present in the database but absent from your entities is proposed for `DROP`, a
breaking change, so startup blocks rather than deleting anything. Either name the foreign tables
(`m.ExcludeTable("LegacyAudit")`) or opt out wholesale (`m.ManageDeclaredTablesOnly()`). The
trade-off of the latter: renaming an entity leaves the old table behind.

## Gotchas

- **`SchemaVersion.Hash` is derived, not supplied**, and it IS comparable across sides: both the
  generated schema and an introspected one are hashed by the same canonical function, which
  normalises types and defaults the way the diff does. `current.Hash == MyDbSchema.Current.Hash`
  means the database is up to date; that is what the runner's fast path and
  `pragmatic-migrate status` rely on.
- **The audit table is a record, not an input.** What gets applied comes from diffing the entities
  against the live database. Losing `__PragmaticSchema` costs history, not correctness, but losing
  `__PragmaticDataMigrations` makes every `IDataMigration` run again.
- **A rename needs a hint.** Put `[RenamedFrom("FirstName")]` (`Pragmatic.Persistence.Entity`) on the
  renamed property and the migration emits `RENAME COLUMN`; without it a rename is a drop plus an add,
  and the drop is breaking. The attribute is for properties only: renaming an **entity** is a new
  table and a dropped one.
- **No down migrations.** Rolling back means deploying the previous model.
- **SQLite rebuilds tables.** Nullability, default, type and FK changes are applied by recreating the
  table (rename → recreate → copy → drop), inside the migration transaction. Handled for you; just
  do not call `GenerateChangeScript` yourself for those changes; it throws, by design, rather than
  returning SQL that looks applied but is not.
- **PostgreSQL introspects every non-system schema.** In a multi-schema database, expect tables you
  did not declare to show up in the diff.

## Related

- `pragmatic-use-persistence`: the `[Entity]` model the desired schema is generated from
- `pragmatic-use-multitenancy`: DB-per-tenant setup
- Module docs: `Pragmatic.Migrations/docs/` (concepts, getting-started, common-mistakes, troubleshooting)

## Examples

Complete files in [`examples/`](examples/README.md), copied from the Warehouse and Showcase example
applications (code that compiles and that `Warehouse.IntegrationTests` and `Showcase.IntegrationTests`
exercise) and kept identical to it by the gate: a service's host migrating at start, its database
marker, the schema snapshot and its project setting, and the runner refusing a breaking change, running a
data migration once and leaving a foreign table alone.

`DryRun()`/`Force()` in a host, `IMigrationSeedProvider`, `IMigrationHook` and the CLI are used by no
tested application yet, so there is no example of them here: the sections above are the reference.