Weasel Grows Up on EF Core
Weasel is the low-level database schema management and migration engine extracted from Marten — the same delta-detection and DDL-generation machinery the entire Critter Stack runs on. Over the last three NuGet releases (culminating in 9.18.0 and 9.18.1) the Weasel.EntityFrameworkCore package went from "maps most of an EF Core model" to a genuinely production-grade bridge between Entity Framework Core and Weasel's schema tooling.
This post walks through what landed, with runnable samples and links to the docs. The headline items:
- Byte-for-byte schema parity between the schema Weasel generates for a
DbContextand the schema EF Core's own migrations would create — verified at the database-catalog level for PostgreSQL and SQL Server. - EF Core migration file generation — the reverse direction, where Weasel emits standard, compilable EF migration artifacts (
dotnet ef database update, idempotent scripts, bundles) from its schema model. - A customization hook (new in 9.18.1) that lets callers such as Wolverine's conjoined multi-tenancy decorate mapped tables and inject extra schema objects into the same migration.
Plus the supporting cast that makes the integration pleasant day to day: an FK-aware database cleaner for tests, a batched-query API, and full JSON column mapping.
Why an EF Core bridge at all?
Weasel's job is schema migration: define database objects programmatically, detect what changed against a live database, and generate the DDL to reconcile them. Marten already uses Weasel internally. The EF Core package lets you point that same machinery at a schema you defined through EF Core's fluent API.
The big payoff is mixed applications — Marten (or Wolverine, or Polecat) for event-sourced/document storage and EF Core for relational entities, living in one database and managed by one migration tool. Both flows read from the same source of truth, IDatabase.AllObjects().
📄 EF Core Integration Overview
1. Schema parity: the mapping now matches EF Core exactly
The guiding principle of the mapping sweep was blunt: the schema Weasel creates for a DbContext should be the schema EF Core's own migration system would create — and we should be able to prove it.
So we built a dual-schema comparison harness. For each permutation DbContext:
- EF Core creates the schema via its own
GenerateCreateScript(), and a neutral catalog introspector (queryingpg_catalog/sys.*directly) snapshots the result. - Weasel's delta detection runs against that EF-created schema — it must report
SchemaPatchDifference.None. Weasel must never want to "migrate" a schema EF just made. - Weasel then builds the schema from scratch and the two catalog snapshots are diffed field by field.
That harness surfaced — and we fixed — a pile of real divergences that existed on master:
- PostgreSQL identifier casing. EF emits quoted PascalCase (
"BlogId"); Weasel used to fold everything to lowercase, so EF's own SQL couldn't find its columns. There's now an opt-inITable.PreserveIdentifierCaseseam (set automatically by the mapper) with case-insensitive delta comparison throughout. All-lowercase callers like Marten still emit byte-identical DDL. - Indexes are mapped now — including EF's conventional
IX_*FK indexes, composite, unique, filtered, and covering/INCLUDEindexes, plus alternate keys as unique indexes. Previously Weasel treated EF's indexes as unknown extras and aCreateOrUpdatemigration would have dropped them. - Literal defaults (
HasDefaultValue(...)) render through EF's own SQL literal generator, soCAST(1 AS bit),N'...',TRUE, enum-to-string conversions, etc. match EF's output exactly. - Delete behaviors: EF's client-side behaviors (
ClientSetNull,ClientCascade,ClientNoAction) now correctly emit noON DELETEclause. - Identity / value generation is mapped —
GENERATED BY DEFAULT AS IDENTITY(PostgreSQL) /IDENTITY(1,1)(SQL Server) — with the right suppressions for TPT/owned linking keys and non-integral types.
Here's the core mapping API. MapToTable() turns an EF IEntityType into a Weasel ITable:
var migrator = new PostgresqlMigrator(); // or SqlServerMigrator
using var context = dbContext;
foreach (var entityType in DbContextExtensions.GetEntityTypesForMigration(context))
{
var table = migrator.MapToTable(entityType);
// table is now a Weasel ITable with full schema definition
}To pull in model sequences (from UseHiLo, UseSequence, HasSequence) and the tables, in dependency order:
// Sequences first, then the mapped tables — so column defaults that
// reference them (NEXT VALUE FOR ...) are valid when tables are created
var schemaObjects = DbContextExtensions.GetSchemaObjectsForMigration(context, migrator);The mapper handles TPH (derived required properties → nullable columns), TPT (each type its own table, keyed PK-as-FK with no identity), table-split and JSON owned entities, topological FK sorting, and check constraints. Full matrix in the docs.
Escaping the neutral seam
Provider-specific index features that the neutral mapping can't express — Npgsql HasMethod("gin"), descending sort — go through a customization hook that downcasts to the concrete provider Table. (More on that hook in section 3.)
2. EF Core migration generation — the reverse direction
This is the big one. Instead of Weasel applying schema changes itself, it can now emit standard EF Core migration files that your team applies with the tooling it already knows.
Weasel-native (db-patch / db-apply) | EF migration generation (db-ef-migration) | |
|---|---|---|
| Schema applied by | Weasel at startup or CLI | EF Core toolchain (dotnet ef, bundles, scripts) |
| Change history | none (delta against live DB) | versioned migration files + __EFMigrationsHistory |
| DBA review artifact | patch SQL file | migration .cs files / idempotent script |
| Best for | dev loops, Marten-style auto-migration | teams standardizing on EF migrations |
Getting started
dotnet run -- db-ef-migration add InitialOr programmatically — note the source is IDatabase, so Marten system tables, Wolverine envelope storage, Polecat event storage, and EF projection tables all flow through one door:
var result = await EfMigrationGenerator.AddAsync(
database,
"Initial",
new EfMigrationGenerationOptions
{
Directory = "WeaselMigrations",
Namespace = "MyApp.WeaselMigrations"
});
// first run writes three artifacts:
// result.MigrationFile -> 20260718120000_Initial.cs (attribute-only migration)
// result.ContextFile -> <Identifier>SchemaDbContext.cs (stub context)
// result.SnapshotFile -> weasel-schema-snapshot.json (design-time baseline)The generated files are ordinary EF migrations. Compile them into a project that references the EF provider package and apply them exactly like any other:
export WEASEL_EF_CONNECTION="Host=localhost;Database=app;..."
dotnet ef database update --context MyStoreSchemaDbContext
# idempotent scripts and bundles work too
dotnet ef migrations script --idempotent --context MyStoreSchemaDbContext -o migrations.sql
dotnet ef migrations bundle --context MyStoreSchemaDbContextThe generated stub DbContext relocates its __EFMigrationsHistory into the critter-stack schema so it never collides with your application's own EF context, suppresses the EF 9+ PendingModelChangesWarning, and ships an IDesignTimeDbContextFactory that reads WEASEL_EF_CONNECTION so the EF CLI works without an application host.
Incremental migrations, diffed in memory
The next db-ef-migration add diffs the current model against a serialized schema snapshot (Weasel's analog of EF's ModelSnapshot) — entirely in memory, no live database, no shadow container — and emits an incremental migration with a real reverse-ordered Down():
var options = new MigrationOperationTranslationOptions(EfMigrationProvider.PostgreSql)
{
Migrator = new PostgresqlMigrator()
};
var table = new PgTable("app.orders");
table.AddColumn<int>("id").AsPrimaryKey();
table.AddColumn<string>("name").NotNull();
var baseline = EfSchemaSnapshot.FromSchemaObjects(new ISchemaObject[] { table }, options);
var json = baseline.ToJson();
// ... later: the model changed
table.AddColumn<string>("tenant_id").NotNull();
var target = EfSchemaSnapshot.FromSchemaObjects(new ISchemaObject[] { table }, options);
var incremental = EfSnapshotDiffer.Diff(EfSchemaSnapshot.FromJson(json), target, options);
// incremental.UpOperations -> AddColumn tenant_id
// incremental.DownOperations -> DropColumn tenant_idFor things the snapshot diff deliberately refuses (partition layout changes, rewritten function bodies), pass --against-database to run Weasel's own delta detection against the live database and wrap the resulting SQL in Sql() operations.
Adopting an existing database
Already have a running Marten/Wolverine app with the schema in place? Baseline it — record the generated migrations as applied without executing anything:
var recorded = await EfMigrationGenerator.BaselineAsync(
database,
new EfMigrationGenerationOptions { Directory = "WeaselMigrations" });Afterwards dotnet ef database update reports nothing pending, and future incrementals apply on top.
What routes through raw SQL
Everything EF can't model goes through migrationBuilder.Sql(...) carrying Weasel's own DDL, so it works from day one: PostgreSQL table partitioning (RANGE/LIST/HASH and the managed strategies), PL/pgSQL functions, SQL Server stored procedures and table types, and expression indexes. v1 providers are PostgreSQL and SQL Server.
📄 EF Core Migration Generation · 📄 Mixed EF + Critter Stack Apps
3. The customization hook (new in 9.18.1)
Real-world integrations need to reach past the neutral mapping — the motivating case was Wolverine's conjoined multi-tenancy, which needs to attach Weasel-managed tenant partitioning to the tables mapped from ITenanted EF entities and inject the partition control/registry tables into the same migration.
That's exactly what EfSchemaMappingCustomization does. It's a two-part hook: decorate each mapped table (CustomizeTable), and contribute additional schema objects that migrate ahead of the entity tables (AdditionalObjects):
// A control/registry table that must be migrated *ahead* of the
// entity tables that depend on it
var partitionRegistry = new PgTable("tenants.partition_registry");
partitionRegistry.AddColumn<string>("tenant_id").AsPrimaryKey();
partitionRegistry.AddColumn<string>("partition_suffix").NotNull();
var customization = new EfSchemaMappingCustomization
{
// Called for every table mapped from an EF entity type, after the
// standard mapping. Downcast to the concrete provider Table to reach
// provider-specific features the neutral seam can't express.
CustomizeTable = (IEntityType entityType, ITable table) =>
{
if (typeof(ITenantScoped).IsAssignableFrom(entityType.ClrType)
&& table is PgTable pgTable)
{
// Attach Weasel-managed LIST partitioning on tenant_id
pgTable.PartitionByList("tenant_id")
.AddPartition("acme", "acme")
.AddPartition("globex", "globex");
}
},
// Extra schema objects migrated ahead of the entity tables
AdditionalObjects = new ISchemaObject[] { partitionRegistry }
};
// The customization flows through delta detection and DDL generation
await using var migration =
await serviceProvider.CreateMigrationAsync(dbContext, customization, ct);The same entity type / table pair is presented on every mapping pass (both CreateDatabase and each CreateMigrationAsync), so partitioning stays attached consistently across delta detection and DDL generation. Contributed objects are emitted first so anything the tables depend on already exists.
📄 Schema Mapping Customization
The supporting cast
FK-aware database cleaner for tests
Inspired by Respawn and Marten's ResetAllData(), the cleaner discovers tables from DbContext metadata, resolves FK ordering, and generates provider-specific truncation SQL:
services.AddDbContext<ShopDbContext>(o => o.UseNpgsql("Host=localhost;Database=mydb"));
services.AddSingleton<Migrator, Weasel.Postgresql.PostgresqlMigrator>();
services.AddDatabaseCleaner<ShopDbContext>();
services.AddInitialData<ShopDbContext, TestOrderSeedData>();var cleaner = host.Services.GetRequiredService<IDatabaseCleaner<ShopDbContext>>();
// Truncate all tables in FK-safe order (children first)
await cleaner.DeleteAllDataAsync();
// ...or delete + re-run registered IInitialData<T> seeders
await cleaner.ResetAllDataAsync();- PostgreSQL:
TRUNCATE ... RESTART IDENTITY CASCADE - SQL Server: FK-ordered
DELETE+DBCC CHECKIDENT - SQLite / MySQL / Oracle each get their appropriate strategy
The dependency graph and generated SQL are memoized on first use — zero overhead across hundreds of tests.
Batched queries — one round trip, many queries
A long-standing EF Core feature request (dotnet/efcore#10879), modeled on Marten's IBatchedQuery:
await using var batch = context.CreateBatchQuery();
var customersTask = batch.Query(context.Customers.Where(c => c.Name.StartsWith("A")));
var ordersTask = batch.Query(context.Orders.Where(o => o.Status == "Pending"));
// Single database round trip for both queries
await batch.ExecuteAsync();
var customers = await customersTask;
var orders = await ordersTask;On a local SQL Server with 4 keyed lookups per handler, batching delivered a 2.78× speedup (6.92 ms → 2.49 ms); the win grows with query count and network latency.
JSON columns
OwnsOne().ToJson() mappings are picked up and rendered as jsonb (PostgreSQL) / nvarchar(max) (SQL Server) columns, with nullability driven by IsRequired().
Try it
dotnet add package Weasel.EntityFrameworkCoreYou'll still want a database-specific Weasel package (Weasel.Postgresql or Weasel.SqlServer) for the Migrator implementation.
Whether you're standardizing a mixed Marten + EF Core application on a single migration tool, generating reviewable EF migration files for your DBAs, or just want a fast FK-aware reset in your test suite — the EF Core bridge in Weasel 9.18 is ready.
Weasel is developed and maintained by JasperFx Software.
