Advanced Migrations — Custom SQL, Data Migrations and Squashing

EF Core migrations manage database schema changes as versioned C# classes. The basic migration workflow is well-known, but production scenarios require more — adding custom SQL for views and stored procedures, data migrations to populate new columns, and handling the accumulation of hundreds of migration files over time. migrationBuilder.Sql() is the escape hatch for anything EF Core cannot express in its migration DSL.

Advanced Migration Patterns

// ── Add custom SQL to a migration ─────────────────────────────────────────
// dotnet ef migrations add AddPublishedPostView
public partial class AddPublishedPostView : Migration
{
    protected override void Up(MigrationBuilder mb)
    {
        // Create a view — EF Core has no native view support
        mb.Sql(@"
            CREATE OR ALTER VIEW dbo.vw_PublishedPostSummary
            WITH SCHEMABINDING AS
            SELECT p.Id, p.Title, p.Slug, p.Excerpt, p.ViewCount, p.CommentCount,
                   p.IsFeatured, p.PublishedAt, p.UpdatedAt,
                   u.DisplayName AS AuthorName, u.AvatarUrl AS AuthorAvatarUrl,
                   c.Name AS CategoryName, c.Slug AS CategorySlug
            FROM   dbo.Posts p
            JOIN   dbo.Users u ON u.Id = p.AuthorId
            LEFT   JOIN dbo.Categories c ON c.Id = p.CategoryId
            WHERE  p.IsPublished = 1 AND p.Status = 'published';");

        // Create an index — EF Core HasIndex is limited
        mb.Sql(@"
            CREATE NONCLUSTERED INDEX IX_Posts_Published_Date
            ON dbo.Posts (IsPublished, PublishedAt DESC)
            INCLUDE (Title, Slug, Excerpt, ViewCount, AuthorId, CategoryId)
            WHERE IsPublished = 1;");
    }

    protected override void Down(MigrationBuilder mb)
    {
        mb.Sql("DROP VIEW IF EXISTS dbo.vw_PublishedPostSummary;");
        mb.Sql("DROP INDEX IF EXISTS IX_Posts_Published_Date ON dbo.Posts;");
    }
}

// ── Data migration — populate new column without losing data ──────────────
// Scenario: Add WordCount column to Posts, populate from Body
public partial class AddWordCountColumn : Migration
{
    protected override void Up(MigrationBuilder mb)
    {
        // Step 1: Add nullable column (cannot add NOT NULL without default on existing data)
        mb.AddColumn<int>("WordCount", "Posts", nullable: true);

        // Step 2: Populate from existing data
        mb.Sql(@"
            UPDATE dbo.Posts
            SET    WordCount = LEN(TRIM(Body)) - LEN(REPLACE(TRIM(Body), ' ', '')) + 1
            WHERE  Body IS NOT NULL;");

        // Step 3: Set NOT NULL after all rows have values
        mb.AlterColumn<int>("WordCount", "Posts", nullable: false, defaultValue: 0);
    }

    protected override void Down(MigrationBuilder mb) =>
        mb.DropColumn("WordCount", "Posts");
}

// ── HasData seed — for reference/lookup data ──────────────────────────────
protected override void OnModelCreating(ModelBuilder mb)
{
    mb.Entity<Category>().HasData(
        new Category { Id = 1, Name = ".NET Development", Slug = "dotnet",    SortOrder = 1 },
        new Category { Id = 2, Name = "Angular",          Slug = "angular",   SortOrder = 2 },
        new Category { Id = 3, Name = "SQL Server",       Slug = "sqlserver", SortOrder = 3 }
    );
}
// dotnet ef migrations add SeedCategories — generates INSERT in migration

// ── Migration bundles — deploy without dotnet ef CLI ─────────────────────
// dotnet ef migrations bundle --output migrate.exe --self-contained
// On server: ./migrate.exe --connection "Server=prod;..."
Note: Always implement both Up() and Down() for custom SQL migrations. The Down() method must reverse the Up() changes — DROP VIEW for CREATE VIEW, DROP INDEX for CREATE INDEX. Without proper Down(), rolling back a migration in production leaves the database in an inconsistent state. Test the rollback locally before deploying: dotnet ef database update PreviousMigrationName should cleanly revert all changes.
Tip: For the add-nullable-then-populate-then-make-not-null pattern, always check whether the data population step can complete within an acceptable time window. On a Posts table with 1 million rows, the UPDATE to populate WordCount may take minutes and hold locks. In production, consider: running the UPDATE in batches (10,000 rows at a time), scheduling it during off-peak hours, or keeping the column nullable in production and populating asynchronously. Never run a multi-minute UPDATE inside a migration that runs synchronously at deployment time.
Warning: HasData() seed data bakes static data into migrations — every data change requires a new migration. This works for true reference data (lookup tables that never change) but becomes painful for frequently changing data (feature flags, configuration values). For data that changes independently of schema, use a separate seeding mechanism (a startup service that upserts reference data) rather than HasData(). HasData is best for data that must exist for the application to function — not for content or configuration.

Common Mistakes

Mistake 1 — Adding NOT NULL column without default to existing table (migration fails)

❌ Wrong — AddColumn<int>("WordCount", nullable: false) on a table with rows; SQL Server cannot add NOT NULL without a value for existing rows.

✅ Correct — add nullable, populate, then AlterColumn to NOT NULL with a default value.

Mistake 2 — Missing Down() implementation for custom SQL (rollback leaves database inconsistent)

❌ Wrong — custom SQL in Up() with no Down(); rolling back the migration fails or leaves view/index orphaned.

✅ Correct — always implement Down() that exactly reverses the Up() custom SQL.

🧠 Test Yourself

A migration uses migrationBuilder.Sql() to create a stored procedure. The team wants to modify the procedure later. Should they use ALTER PROCEDURE in the existing migration or create a new migration?