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 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 ) You write this:
Create.Table("Products") .WithColumn("Id").AsInt32().PrimaryKey().Identity() .WithColumn("Name").AsString(200).NotNullable() .WithColumn("Price").AsDecimal(18, 2).NotNullable(); 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 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 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=***"); } 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;" 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! 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"); } } 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 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 Don't forget to mark SQL files as embedded resources in your .csproj:
<ItemGroup> <EmbeddedResource Include="Scripts\**\*.sql" /> </ItemGroup> Best Practices
✅ Do
- One change per migration - Keep it focused
- Always write Down() - Enable rollbacks
- Use descriptive names -
M003_AddEmailToUsersnotM003_Update - Use version numbers -
YYYYMMDD + sequence(e.g., 20251128001) - Test before committing - Run locally first
❌ Don't
- Modify existing migrations - Create new ones instead
- Delete migrations - They're your history
- Skip Down() methods - You'll need rollbacks eventually
- 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 Running and Validating
Start the entire stack:
docker compose up -d Validate migrations ran successfully:
# Check migration logs docker logs db-migrate 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:
- Creates a
VersionInfotable (if it doesn't exist) - Records each migration's version number after successful execution
- 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 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 Why This Matters
Prevents duplicate execution:
- Run
dotnet runmultiple 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; 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:
- Add FluentMigrator to your existing project
- Create migrations for your current schema
- 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)