DEV Community

Daniel Jonathan
Daniel Jonathan

Posted on

Getting Started with Database Migrations using FluentMigrator

Why Your Database Schema Deserves Version Control

Your code is tracked, reviewed, and tested.
Your database schema? Not always.

If you’ve ever found yourself unsure whether a schema change was applied consistently across environments, this guide will help.


The Problem

Your application code lives in Git, but your database schema lives... where exactly?

When you:

  • Add a new column
  • Create a new table
  • Modify an index

How do you ensure every developer and every environment has the same schema?

The old way: Scattered SQL scripts, manual execution, fingers crossed.

The better way: Migrations as code—version-controlled, repeatable, and reviewable.


What Are Database Migrations?

Think of migrations as "Git commits for your database schema."

Each migration is a numbered file that describes a single change:

Migration 001: Create Users table Migration 002: Create Products table Migration 003: Add email column to Users Migration 004: Create index on Products.SKU 
Enter fullscreen mode Exit fullscreen mode

Each migration can be:

  • Committed to Git
  • Code reviewed
  • Applied in order
  • Rolled back if needed

Enter FluentMigrator

FluentMigrator is a .NET library that lets you write database migrations in C# instead of SQL.

Instead of this:

CREATE TABLE Products ( Id INT PRIMARY KEY IDENTITY, Name NVARCHAR(200) NOT NULL, Price DECIMAL(18,2) NOT NULL ) 
Enter fullscreen mode Exit fullscreen mode

You write this:

Create.Table("Products") .WithColumn("Id").AsInt32().PrimaryKey().Identity() .WithColumn("Name").AsString(200).NotNullable() .WithColumn("Price").AsDecimal(18, 2).NotNullable(); 
Enter fullscreen mode Exit fullscreen mode

Benefits:

  • Type-safe (compile-time checks)
  • Database-agnostic (works with SQL Server, PostgreSQL, MySQL, etc.)
  • Easy to test
  • Familiar C# syntax

Real-World Example: ProductWebAPI with FluentMigrator

Let me show you a real production example from the ProductWebAPI project.

Step 1: Create a Migration Project

dotnet new console -n ProductWebAPI.Database cd ProductWebAPI.Database dotnet add package FluentMigrator dotnet add package FluentMigrator.Runner dotnet add package FluentMigrator.Runner.SqlServer dotnet add package Microsoft.Data.SqlClient 
Enter fullscreen mode Exit fullscreen mode

Project Structure:

ProductWebAPI.Database/ ├── ProductWebAPI.Database.csproj ├── Program.cs # Migration runner ├── Dockerfile # Docker support ├── Migrations/ │ ├── M001_CreateInitialSchema.cs │ ├── M002_SeedInitialData.cs │ └── M003_CreateViews.cs └── Scripts/ └── Views/ ├── vw_ProductSummary.sql └── vw_CategoryProductCount.sql 
Enter fullscreen mode Exit fullscreen mode

Step 2: Write Your First Migration

Here's the actual Migration 1 from ProductWebAPI - it creates a complete e-commerce schema:

File: Migrations/M001_CreateInitialSchema.cs

What this creates:

  • 3 tables: Categories, Products, ProductMetadata
  • 2 foreign keys: Products → Categories, ProductMetadata → Products (with cascade)
  • 3 indexes: For query performance
  • 1 unique constraint: Prevents duplicate metadata keys per product

Step 3: Create the Migration Runner

Here's the actual Program.cs from ProductWebAPI.Database:


// Helper: Ensure database exists static void EnsureDatabaseExists(string connectionString) { var builder = new Microsoft.Data.SqlClient.SqlConnectionStringBuilder(connectionString); var databaseName = builder.InitialCatalog; builder.InitialCatalog = "master"; using var connection = new Microsoft.Data.SqlClient.SqlConnection(builder.ConnectionString); connection.Open(); using var command = connection.CreateCommand(); command.CommandText = $@"  IF NOT EXISTS (SELECT name FROM sys.databases WHERE name = N'{databaseName}')  BEGIN  CREATE DATABASE [{databaseName}]  END"; command.ExecuteNonQuery(); Console.WriteLine($"Database '{databaseName}' is ready."); } // Helper: Mask password in connection string static string MaskConnectionString(string connectionString) { return System.Text.RegularExpressions.Regex.Replace( connectionString, @"Password=([^;]+)", "Password=***"); } 
Enter fullscreen mode Exit fullscreen mode

Features:

  • ✅ Auto-creates database if missing
  • ✅ Accepts connection string from args or environment
  • ✅ Masks password in console output
  • ✅ Proper error handling with exit codes
  • ✅ Clear logging

Step 4: Run It!

# Run locally dotnet run # Or specify connection string dotnet run "Server=localhost;Database=ProductDB;User Id=sa;Password=Pass123!;TrustServerCertificate=True;" 
Enter fullscreen mode Exit fullscreen mode

Output:

=== Database Migrator === Target Database: Server=localhost,1433;Database=ProductDB;User Id=sa;Password=***;TrustServerCertificate=True; Creating database if it doesn't exist... Database 'ProductDB' is ready. Running migrations... 20251128001: CreateInitialSchema migrating 20251128001: CreateInitialSchema migrated ✅ Migrations completed successfully! 
Enter fullscreen mode Exit fullscreen mode

Your database now has a complete e-commerce schema!


More Real Examples from ProductWebAPI

Migration 2: Seeding Initial Data

Here's the actual Migration 2 that seeds the database with initial categories and products:

File: Migrations/M002_SeedInitialData.cs

Migration 3: Creating SQL Views with Embedded Scripts

Here's how to create SQL Server views using FluentMigrator with embedded SQL files:

File: Migrations/M003_CreateViews.cs

using FluentMigrator; namespace ProductWebAPI.Database.Migrations; [Migration(20251128003)] public class CreateViews : Migration { public override void Up() { // Execute embedded SQL scripts Execute.EmbeddedScript("ProductWebAPI.Database.Scripts.Views.vw_ProductSummary.sql"); Execute.EmbeddedScript("ProductWebAPI.Database.Scripts.Views.vw_CategoryProductCount.sql"); } public override void Down() { Execute.Sql("DROP VIEW IF EXISTS vw_ProductSummary"); Execute.Sql("DROP VIEW IF EXISTS vw_CategoryProductCount"); } } 
Enter fullscreen mode Exit fullscreen mode

File: Scripts/Views/vw_ProductSummary.sql

CREATE VIEW vw_ProductSummary AS SELECT p.Id, p.Name AS ProductName, p.SKU, p.Description, p.Price, c.Id AS CategoryId, c.Name AS CategoryName, CASE WHEN p.Price > 500 THEN 'Premium' WHEN p.Price > 100 THEN 'Standard' ELSE 'Budget' END AS PriceTier, p.IsActive, p.CreatedAt, p.UpdatedAt FROM Products p INNER JOIN Categories c ON p.CategoryId = c.Id WHERE p.IsActive = 1 
Enter fullscreen mode Exit fullscreen mode

File: Scripts/Views/vw_CategoryProductCount.sql

CREATE VIEW vw_CategoryProductCount AS SELECT c.Id AS CategoryId, c.Name AS CategoryName, c.Description, COUNT(p.Id) AS TotalProducts, COUNT(CASE WHEN p.IsActive = 1 THEN 1 END) AS ActiveProducts, COUNT(CASE WHEN p.IsActive = 0 THEN 1 END) AS InactiveProducts, ISNULL(SUM(p.Price), 0) AS TotalInventoryValue, ISNULL(AVG(p.Price), 0) AS AveragePrice, c.CreatedAt AS CategoryCreatedAt FROM Categories c LEFT JOIN Products p ON c.Id = p.CategoryId GROUP BY c.Id, c.Name, c.Description, c.CreatedAt 
Enter fullscreen mode Exit fullscreen mode

Don't forget to mark SQL files as embedded resources in your .csproj:

<ItemGroup> <EmbeddedResource Include="Scripts\**\*.sql" /> </ItemGroup> 
Enter fullscreen mode Exit fullscreen mode

Best Practices

✅ Do

  1. One change per migration - Keep it focused
  2. Always write Down() - Enable rollbacks
  3. Use descriptive names - M003_AddEmailToUsers not M003_Update
  4. Use version numbers - YYYYMMDD + sequence (e.g., 20251128001)
  5. Test before committing - Run locally first

❌ Don't

  1. Modify existing migrations - Create new ones instead
  2. Delete migrations - They're your history
  3. Skip Down() methods - You'll need rollbacks eventually
  4. Use hard-coded data - Use configuration or seed migrations

Running in Docker with Validation

Here's the actual Docker setup from ProductWebAPI that runs migrations automatically.

Dockerfile for the Migrator

File: ProductWebAPI.Database/Dockerfile

Complete Docker Compose Setup

File: docker-compose.yml

Key features:

  • Health checks - SQL Server must be ready before migrations run
  • Ordered startup - sqlserver → db-migrate → product-api
  • Run-once migrations - restart: "no" prevents re-running

Startup Sequence

1. sqlserver → Starts, healthcheck validates SQL is ready 2. db-migrate → Runs all migrations, exits with code 0 
Enter fullscreen mode Exit fullscreen mode

Running and Validating

Start the entire stack:

docker compose up -d 
Enter fullscreen mode Exit fullscreen mode

Validate migrations ran successfully:


# Check migration logs docker logs db-migrate 
Enter fullscreen mode Exit fullscreen mode

Understanding the VersionInfo Table

This is critical: FluentMigrator automatically creates a VersionInfo table to track which migrations have been executed.

How It Works

When you run migrations, FluentMigrator:

  1. Creates a VersionInfo table (if it doesn't exist)
  2. Records each migration's version number after successful execution
  3. Skips migrations that already exist in VersionInfo on subsequent runs

Query the version table:

bash # Connect to SQL Server docker exec -it sqlserver-dev /opt/mssql-tools18/bin/sqlcmd \ -S localhost -U sa -P "YourStrongPassword123!" -C # Check migration history SELECT Version, AppliedOn, Description FROM VersionInfo ORDER BY AppliedOn; GO 
Enter fullscreen mode Exit fullscreen mode

Example output:

Version AppliedOn Description -------------- -------------------------- ---------------------------------- 20251128001 2025-11-28 10:15:23.123 CreateInitialSchema 20251128002 2025-11-28 10:15:24.456 SeedInitialData 20251128003 2025-11-28 10:15:25.789 CreateViews 
Enter fullscreen mode Exit fullscreen mode

Why This Matters

Prevents duplicate execution:

  • Run dotnet run multiple times → Only new migrations execute
  • Safe to run in CI/CD repeatedly → No data duplication
  • Rolling deployments work correctly → Each instance checks VersionInfo

Manual override (use with caution):

If you need to re-run a migration (development only):

sql -- Remove a migration from history (THIS WILL RE-RUN IT) DELETE FROM VersionInfo WHERE Version = 20251128002; 
Enter fullscreen mode Exit fullscreen mode

Production rule: Never delete from VersionInfo in production. Always create a new migration to fix issues.


What's Next?

You now have:

  • ✅ Version-controlled database schema
  • ✅ Repeatable deployments
  • ✅ Rollback capability
  • ✅ Team collaboration without conflicts

Next steps:

  1. Add FluentMigrator to your existing project
  2. Create migrations for your current schema
  3. Set up automated migrations in CI/CD (see Part 2)

Key Takeaways

  • Database migrations are version control for your schema
  • FluentMigrator makes migrations type-safe and database-agnostic
  • Each migration has Up() and Down() methods
  • Version numbers determine execution order
  • Migrations work great with Docker and CI/CD

Stop worrying about "Did I update the database?" and start treating your schema as code.

Happy migrating!

Top comments (0)