Chapter 21 · PostgreSQL, MySQL, and Cross-Provider Portability

Migration Portability: Why One EF Model Does Not Guarantee One Portable Schema

Generate provider-specific migration artifacts and show why identifiers, types, sequences, defaults, computed/generated values, indexes, schemas, renames and raw SQL make one shared migration stream fragile across unlike databases.

Advanced170–220 minutesprovider-specific migration labEF Core 10.0.11 · Npgsql EF provider 10.0.3 · Oracle MySql.EntityFrameworkCore 10.0.9 · PostgreSQL 18.6 · MySQL 8.4 LTS · .NET 10.0.11 · SDK 10.0.400Free local PostgreSQL/MySQL path · MariaDB/Pomelo EF9 observation onlyProvider compatibility reviewed: August 27, 2026

Learning outcomes

01

Explain why EF migrations are provider-specific schema-change artifacts even when they originate from one CLR model.

02

Compare PostgreSQL and MySQL DDL for identifiers, generated keys, types, defaults, indexes and provider-specific annotations.

03

Use separate migration assemblies/streams when supported providers need materially different schema history.

04

Keep raw SQL, rename/data-move steps and extension/index features inside the owning provider migration path.

05

Test migrations from empty database, prior supported version and rollback/recovery path on every supported provider.

06

Prevent production deployment from selecting the wrong migration assembly or provider at runtime.

1. The problem: the model snapshot is relational, but migration operations are provider-rendered

EF migrations scaffold differences between the current EF model and the previous model snapshot. The generated operations then become SQL through the active provider. PostgreSQL has schemas, sequences, arrays and jsonb; MySQL has different identity, charset/collation, JSON/generated-column and index syntax. A single migration source file can quickly accumulate provider checks and raw SQL that are difficult to review or safely roll back.

One model is not one portable schema

A shared domain/model can be valuable while migration history remains provider-specific. Portability should not require pretending every provider has identical DDL capabilities.

2. Generate provider DDL side by side before choosing a migration strategy

Start with the same logical WorkOrder model and produce scripts under each provider configuration. Compare what the database will actually receive.

csharp · provider-specific design-time context selection
public sealed class PostgresDesignFactory    : IDesignTimeDbContextFactory<ServiceHubContext>{    public ServiceHubContext CreateDbContext(string[] args)    {        var cs = Environment.GetEnvironmentVariable("SERVICEHUB_PG")!;        var options = new DbContextOptionsBuilder<ServiceHubContext>()            .UseNpgsql(cs, o => o.MigrationsAssembly("ServiceHub.Migrations.Postgres"))            .Options;        return new ServiceHubContext(options);    }}// MySQL project uses UseMySQL(...) and// MigrationsAssembly("ServiceHub.Migrations.MySql").
bash · separate migration projects
dotnet ef migrations add Pg_Initial   --project src/ServiceHub.Migrations.Postgres   --startup-project src/ServiceHub.Persistence.Postgresdotnet ef migrations add MySql_Initial   --project src/ServiceHub.Migrations.MySql   --startup-project src/ServiceHub.Persistence.MySql

3. The same ValueGeneratedOnAdd can yield different DDL

A generated integer key may become PostgreSQL identity backed by a sequence and MySQL AUTO_INCREMENT. Defaults, booleans, GUID/UUID representations, timestamp types, JSON, computed/generated expressions and identifier quoting differ too.

Model concern PostgreSQL migration/DDL tendency MySQL migration/DDL tendency
Generated int key IDENTITY / sequence options AUTO_INCREMENT
Schema namespace native schemas supported database/schema concepts differ; EF schema behavior is provider-specific
JSON jsonb/json and PostgreSQL operators JSON with MySQL functions/generated columns
Array native array type available no equivalent native relational array
Index extension GIN/GiST/include/operator-class possibilities prefix/generated/functional/index options differ
Case/collation PostgreSQL collation/quoted identifiers charset + collation and platform-sensitive identifier rules

A reviewer should be able to see those differences in separate migration commits rather than decode a giant provider conditional.

4. Provider-specific annotations belong in provider-specific model construction or migrations

If the PostgreSQL provider adds a GIN index, extension, array type or range, that metadata cannot be expected to render correctly under MySQL. Likewise MySQL generated-column/index/charset details are not Npgsql metadata. Keep those extensions isolated.

csharp · portable core + provider model customization hook
public static void ConfigureCore(ModelBuilder modelBuilder){    // keys, relationships and truly shared constraints}public static void ConfigurePostgres(ModelBuilder modelBuilder){    // Npgsql arrays/jsonb/ranges/index methods, if the product uses them}public static void ConfigureMySql(ModelBuilder modelBuilder){    // MySQL charset/collation/generated-column/provider metadata}

The final runtime model can still be one ServiceHubContext type if model caching/configuration is designed correctly, but many teams prefer separate provider context subclasses or assemblies to make the boundary obvious.

5. Raw SQL is the strongest reason not to share blindly

Migration Sql(...) is not translated by EF. PostgreSQL CREATE EXTENSION, casts, JSON operators, partial indexes or data-move expressions are not MySQL SQL; MySQL generated-column/backfill syntax is not PostgreSQL SQL.

csharp · provider-owned raw SQL
// PostgreSQL migration only:migrationBuilder.Sql("CREATE EXTENSION IF NOT EXISTS pg_trgm;");// MySQL migration only (example shape; review exact target version syntax):migrationBuilder.Sql("UPDATE work_orders SET normalized_code = UPPER(customer_code) WHERE normalized_code IS NULL;");
Never branch on provider inside an unreviewed shared migration and hope for safety

If a migration contains provider-specific raw SQL or destructive data movement, test and deploy it as a provider-owned artifact with its own rollback/forward-fix plan.

6. Renames and data preservation can diverge by provider/version

Even when EF exposes a relational RenameColumn operation, the provider decides how to render it for the target server. Some changes require rebuilds or multi-step transitions on one engine and a direct ALTER on another. Review generated SQL against the actual database version.

csharp · safe expand/backfill/contract intent
// Phase A: add new nullable columnmigrationBuilder.AddColumn<string>(    name: "customer_code_v2",    table: "work_orders",    nullable: true);// Phase B: provider-specific backfill SQL in each migration assembly.// Phase C: application dual-read/write or compatibility window.// Phase D: enforce constraints/drop old column in later migration.

This pattern costs more migrations but is often safer than assuming a provider can atomically rename/convert a hot column with identical lock behavior.

7. Failure case: one shared migration stream contains both providers’ assumptions

The migration was scaffolded while Npgsql was active, so the snapshot includes PostgreSQL annotations and the migration uses a PostgreSQL default expression. A MySQL deployment later reuses it and fails during schema creation—or worse, a “genericized” migration silently stores a different type/collation.

Repair: choose one explicit policy: (A) support only one authoritative provider per migration stream; or (B) maintain separate provider migration assemblies with independent snapshots and CI. For materially different engines in this chapter, policy B is the clearer teaching/production path.

8. Migration CI needs multiple starting states, not only empty databases

A migration can create a fresh database successfully and still fail on a real upgrade because production contains data, older indexes, constraints or provider-version differences. Each provider pipeline should test:

Test state Why it matters
Empty → latest catches missing provider DDL/configuration
Previous supported version → latest catches upgrade/data-transform problems
Representative data + constraints catches truncation/collation/type conversion risks
Generated SQL review catches destructive/locking/provider-specific operations
Rollback or forward-fix drill proves operational recovery, not just Down() syntax

9. Mandatory lab: maintain two provider migration streams

  1. Create ServiceHub.Migrations.Postgres and ServiceHub.Migrations.MySql with provider-specific design-time factories/options.
  2. Scaffold an initial migration for the same portable ServiceHub core model under each provider; compare the snapshots and DDL.
  3. Add a provider-specific extension: PostgreSQL array/jsonb/search artifact and a MySQL collation/generated/JSON artifact. Confirm it appears only in its owning migration stream.
  4. Add a portable column rename/data evolution and inspect how both providers render it.
  5. Generate bounded scripts from a known prior migration for each provider and review them for data-loss/locking risks.
  6. CI-test empty→latest and prior→latest against PostgreSQL 18.6 and MySQL 8.4 LTS disposable databases.
  7. Attempt to point the MySQL deployment at the PostgreSQL migration assembly in a disposable environment; assert failure/mismatch and add a startup/deployment guard.
  8. Drop all lab databases.

10. Production judgment and bridge

Migration portability is usually the wrong goal once providers have materially different physical capabilities. Keep migration ownership explicit, review provider SQL, and test every supported upgrade path against the real engine. The shared contract is the application’s data semantics and compatibility window—not one identical migration file. Lesson 5 turns this into an architecture boundary that keeps native capabilities intentional without infecting every service with provider conditionals.

Check your understanding

  1. Why can one EF model produce different migrations?
  2. When are separate migration assemblies preferable?
  3. Is migrationBuilder.Sql portable?
  4. Why test previous-version→latest instead of only empty→latest?
  5. What is the shared contract during expand/contract?
  6. What should deployment verify before applying migrations?
Review the answers

1. Migration operations and annotations are rendered through the active provider, whose database capabilities, type mappings and DDL syntax differ.

2. When supported providers have materially different types, indexes, raw SQL, schemas, generated values or operational migration behavior.

3. No. EF does not translate arbitrary raw migration SQL; it belongs to the provider whose dialect it targets.

4. Real upgrades include existing data, constraints and provider-version state that fresh creation cannot expose.

5. Old and new application versions can coexist while data remains valid; provider-specific DDL may implement that contract differently.

6. Expected provider/database identity, correct migration assembly/history, reviewed script/bundle, permissions, backup/recovery plan and target server version.

Authoritative references

Migration artifacts must be reviewed in the context of the provider that renders them.

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.

\n