Introduction
Historically, schema changes (CREATE TABLE, ALTER, INSERT data etc.) were handled manually by DBAs — an approach that is slow, error-prone, and blocks developers, especially with CI/CD workflows and distributed teams.
Modern best practice is to manage these as code, using migration & seeder frameworks built into your language or app stack. This means:
- Developers write migration/seeder files as part of their feature/bugfix code.
- These are reviewed via Pull Request, just like application code.
- CI/CD pipelines automatically apply them during deployment.
- Nobody forgets to add a column or table in production again.
Key Concepts
| Concept | Description |
|---|---|
| Migration | Versioned scripts for schema changes (CREATE TABLE, ALTER, ADD COLUMN, etc.). Applied incrementally and tracked. |
| Seeder | Scripted insertion/update of reference, lookup, or initial data. |
| Idempotency | Scripts run only once; never duplicate changes or data. Tracked by the framework automatically. |
| Baseline | A starting migration that matches your current live schema — used when introducing migrations to an existing app. |
| Rollback | Undo a migration if something goes wrong (not all operations support this). |
| Validation | Framework tracks what has/hasn't run and alerts on drift. |
Benefits
- All DB changes are versioned, code-reviewed, and reproducible.
- Zero-wait for DBA — faster feature and bugfix delivery.
- All environments (local, dev, staging, prod) stay in sync.
- No more "I forgot to add the column/table in prod" — migrations run automatically as part of CI/CD deployment.
- Reliable rollbacks and easy investigation of changes.
- Full audit trail: "Who changed what, when, and why."
- Self-documenting schema history in your codebase.
- New developer onboarding is seamless — run migrations and the DB is ready.
Drawbacks / Points to Consider
- Requires discipline — manual DB changes must stop completely.
- Resolving legacy DB drift can be a one-time effort when first adopting.
- Potential merge conflicts if multiple developers touch the same table at once.
- Some complex legacy schema quirks may need a careful baseline.
- Destructive operations (DROP COLUMN, etc.) need extra caution and testing.
Why Not Just Use Hibernate ddl-auto=update?
(Applies to Spring Boot / Java with Hibernate)
Many Spring Boot teams rely on:
spring.jpa.hibernate.ddl-auto=update
This auto-generates schema changes to match your JPA entities. While convenient for early development/prototyping, it is not suitable for production:
ddl-auto=update |
Flyway/Liquibase Migration | |
|---|---|---|
| Versioned/tracked | No | Yes |
| Code reviewed | No | Yes |
| Rollback support | No | Yes |
| Data seeding | No | Yes |
| CI/CD safe | Risky | Yes |
| Audit trail | No | Yes |
| Complex changes (rename, index) | Limited | Full control |
| Safe for production | No | Yes |
Best Practice: In production, set spring.jpa.hibernate.ddl-auto=validate or none. Use Flyway or Liquibase for all schema and data changes.
How to Adopt in Existing Applications (No Migrations Yet)
If your app is already running with an existing database schema, follow these steps:
Step 1 — Create a Baseline Migration
Export your current schema and save it as your first migration file.
-- V001__baseline.sql (Flyway) OR initial_migration.sql (others)
-- This reflects your CURRENT production schema exactly
CREATE TABLE users ( ... );
CREATE TABLE roles ( ... );
-- etc.
Step 2 — Tell the Migration Tool to Skip the Baseline
Since the DB already has these tables, tell the tool to mark the baseline as "already applied":
| Tool | Command |
|---|---|
| Flyway | ./mvnw flyway:baseline |
| Sequelize | Mark first migration manually in SequelizeMeta |
| Django | python manage.py migrate --fake-initial |
| Laravel | php artisan migrate --pretend then mark manually |
| Phinx | vendor/bin/phinx migrate -t 0 (baseline target) |
Step 3 — All Future Changes via Migration Files Only
From this point, every schema or data change must be a new migration/seeder file. No more manual SQL on the server.
Step 4 — Update CI/CD and Dev Workflow
- Add migration/seed command as a deploy step.
- All migration PRs go through code review.
- No manual DB changes allowed in any environment.
Language & Framework Specific Examples
1. Spring Boot (Java) — Flyway + Hibernate
Dependencies (pom.xml):
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
Configuration (application.properties):
spring.flyway.enabled=true
spring.flyway.locations=classpath:db/migration
spring.jpa.hibernate.ddl-auto=validate
Migration Files (src/main/resources/db/migration/):
-- V001__baseline.sql
CREATE TABLE users (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(255) NOT NULL,
birthdate DATE
);
-- V002__add_email_to_users.sql
ALTER TABLE users ADD COLUMN email VARCHAR(255);
-- V003__create_roles_table.sql
CREATE TABLE roles (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL UNIQUE
);
Seeder Example:
-- V004__seed_roles.sql
INSERT INTO roles (name) VALUES ('ADMIN'), ('USER')
ON DUPLICATE KEY UPDATE name = name;
How to Run: Auto runs on app startup, or manually: ./mvnw spring-boot:run
Check Migration Status:
./mvnw flyway:info
2. NodeJS — Sequelize CLI
Install:
npm install --save sequelize sequelize-cli mysql2
npx sequelize-cli init
Migration Example (migrations/):
// YYYYMMDDHHMMSS-create-users.js
module.exports = {
up: (queryInterface, Sequelize) =>
queryInterface.createTable('users', {
id: { type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true },
name: { type: Sequelize.STRING, allowNull: false },
birthdate: Sequelize.DATE,
email: Sequelize.STRING,
}),
down: (queryInterface) => queryInterface.dropTable('users'),
};
Seeder Example (seeders/):
// YYYYMMDDHHMMSS-seed-roles.js
module.exports = {
up: (queryInterface) =>
queryInterface.bulkInsert('roles', [
{ name: 'ADMIN' },
{ name: 'USER' },
], { ignoreDuplicates: true }),
down: (queryInterface) =>
queryInterface.bulkDelete('roles', null, {}),
};
Run:
npx sequelize-cli db:migrate
npx sequelize-cli db:seed:all
Check Status:
npx sequelize-cli db:migrate:status
3. Python — Django (Built-in Migrations)
Migration:
python manage.py makemigrations
python manage.py migrate
Seeder (Custom management command):
# myapp/management/commands/seed.py
from django.core.management.base import BaseCommand
from myapp.models import Role
class Command(BaseCommand):
def handle(self, *args, **kwargs):
for name in ['ADMIN', 'USER']:
Role.objects.get_or_create(name=name) # Idempotent
self.stdout.write('Seeding complete.')
python manage.py seed
Check Status:
python manage.py showmigrations
4. Python — Flask / FastAPI (Alembic + SQLAlchemy)
Install:
pip install alembic sqlalchemy
alembic init migrations
Migration Example (migrations/versions/):
# xxxx_create_users_table.py
def upgrade():
op.create_table('users',
sa.Column('id', sa.Integer, primary_key=True),
sa.Column('name', sa.String(255), nullable=False),
sa.Column('email', sa.String(255)),
sa.Column('birthdate', sa.Date),
)
def downgrade():
op.drop_table('users')
Seeder (Python script, idempotent):
# seed.py
session = Session()
for role_name in ['ADMIN', 'USER']:
if not session.query(Role).filter_by(name=role_name).first():
session.add(Role(name=role_name))
session.commit()
print('Seeding complete.')
Run:
alembic upgrade head
python seed.py
Check Status:
alembic current
alembic history
5. Laravel (PHP)
Create and Run Migration:
php artisan make:migration add_email_to_users_table
// database/migrations/xxxx_add_email_to_users_table.php
public function up()
{
Schema::table('users', function (Blueprint $table) {
$table->string('email')->nullable();
});
}
public function down()
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('email');
});
}
php artisan migrate
Seeder:
php artisan make:seeder RolesTableSeeder
// database/seeders/RolesTableSeeder.php
public function run()
{
DB::table('roles')->updateOrInsert(['name' => 'ADMIN']);
DB::table('roles')->updateOrInsert(['name' => 'USER']);
}
php artisan db:seed --class=RolesTableSeeder
Check Status:
php artisan migrate:status
6. Core PHP — Phinx
Install:
composer require robmorgan/phinx
Migration Example:
// db/migrations/YYYYMMDDHHMMSS_create_users_table.php
public function change()
{
$table = $this->table('users');
$table->addColumn('name', 'string', ['limit' => 255])
->addColumn('email', 'string', ['limit' => 255, 'null' => true])
->addColumn('birthdate', 'date', ['null' => true])
->create();
}
Seeder:
// db/seeds/RolesSeeder.php
public function run()
{
$data = [['name' => 'ADMIN'], ['name' => 'USER']];
$this->insert('roles', $data);
}
Run:
vendor/bin/phinx migrate
vendor/bin/phinx seed:run
Check Status:
vendor/bin/phinx status
7. Flutter (Android / iOS / Web)
Flutter typically connects to a remote backend API (handled by one of the above stacks). For local/offline storage (SQLite via sqflite):
// database_helper.dart
import 'package:sqflite/sqflite.dart';
class DatabaseHelper {
static const _dbVersion = 3; // Bump this on every schema change
static Future<Database> openDb() async {
return openDatabase(
'app.db',
version: _dbVersion,
onCreate: (db, version) async {
await db.execute('''
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT
)
''');
await db.execute('''
CREATE TABLE IF NOT EXISTS roles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE
)
''');
await _seedRoles(db);
},
onUpgrade: (db, oldVersion, newVersion) async {
if (oldVersion < 2) {
await db.execute('ALTER TABLE users ADD COLUMN birthdate TEXT');
}
if (oldVersion < 3) {
await db.execute('ALTER TABLE users ADD COLUMN phone TEXT');
}
},
);
}
static Future<void> _seedRoles(Database db) async {
await db.execute(
'INSERT OR IGNORE INTO roles (name) VALUES ("ADMIN"), ("USER")'
);
}
}
Note: Always bump _dbVersion when you add changes in onUpgrade. This is your migration versioning for local DB.
Idempotency — Never Run Twice, Never Duplicate
| Stack | How Idempotency is Handled |
|---|---|
| Spring Boot + Flyway | flyway_schema_history table tracks applied scripts |
| NodeJS + Sequelize | SequelizeMeta table tracks applied migrations |
| Django | django_migrations table tracks applied migrations |
| Flask + Alembic | alembic_version table tracks current version |
| Laravel | migrations table tracks applied migrations |
| Phinx | phinxlog table tracks applied migrations |
| Flutter/sqflite | _dbVersion integer controls onUpgrade trigger |
For Seeders — Always use safe insert patterns:
-- MySQL
INSERT INTO roles (name) VALUES ('ADMIN') ON DUPLICATE KEY UPDATE name = name;
-- PostgreSQL
INSERT INTO roles (name) VALUES ('ADMIN') ON CONFLICT (name) DO NOTHING;
-- SQLite (Flutter)
INSERT OR IGNORE INTO roles (name) VALUES ('ADMIN');
Post-Deploy Verification
After deployment, use these console/REPL scripts to verify that migrations and seeders ran correctly.
Laravel — php artisan tinker
php artisan tinker
Schema::hasTable('users'); // true or false
Schema::hasColumn('users', 'email'); // true or false
DB::table('roles')->get();
DB::table('roles')->where('name', 'ADMIN')->exists(); // true or false
Django — python manage.py shell
python manage.py shell
from django.db import connection
tables = connection.introspection.table_names()
print('users' in tables) # True or False
cursor = connection.cursor()
cols = [col.name for col in connection.introspection.get_table_description(cursor, 'users')]
print('email' in cols) # True or False
from myapp.models import Role
print(Role.objects.filter(name='ADMIN').exists()) # True or False
Flask/FastAPI — flask shell
from app import db
from app.models import Role
from sqlalchemy import inspect
inspector = inspect(db.engine)
print('users' in inspector.get_table_names()) # True or False
cols = [c['name'] for c in inspector.get_columns('users')]
print('email' in cols) # True or False
print(Role.query.filter_by(name='ADMIN').first())
NodeJS — Verify Script
// verify.js
const { sequelize, Role } = require('./models');
(async () => {
const qi = sequelize.getQueryInterface();
const tables = await qi.showAllTables();
console.log('[CHECK] Table users:', tables.includes('users') ? 'PASS' : 'FAIL');
const cols = await qi.describeTable('users');
console.log('[CHECK] Column email:', 'email' in cols ? 'PASS' : 'FAIL');
const roles = await Role.findAll();
console.log('[CHECK] Roles seeded:', roles.length > 0 ? `PASS (${roles.length} rows)` : 'FAIL');
await sequelize.close();
})();
node verify.js
Core PHP — Verify Script
<?php
// verify.php
require 'vendor/autoload.php';
$pdo = new PDO('mysql:host=localhost;dbname=your_db', 'user', 'password');
function checkTable(PDO $pdo, string $table): void {
$stmt = $pdo->prepare("SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?");
$stmt->execute([$table]);
echo "[CHECK] Table '$table': " . ($stmt->fetchColumn() > 0 ? "PASS" : "FAIL") . "\n";
}
function checkSeededData(PDO $pdo, string $table): void {
$stmt = $pdo->query("SELECT COUNT(*) FROM $table");
$count = $stmt->fetchColumn();
echo "[CHECK] Data in '$table': " . ($count > 0 ? "PASS ($count rows)" : "FAIL") . "\n";
}
checkTable($pdo, 'users');
checkSeededData($pdo, 'roles');
php verify.php
CI/CD Integration
# Example CI/CD pipeline step (GitHub Actions / GitLab CI / etc.)
deploy:
steps:
- name: Run Migrations
run: |
# NodeJS
npx sequelize-cli db:migrate
# Laravel
php artisan migrate --force
# Django
python manage.py migrate
# Alembic
alembic upgrade head
# Phinx
vendor/bin/phinx migrate
- name: Run Seeders
run: |
npx sequelize-cli db:seed:all
php artisan db:seed --force
python manage.py seed
- name: Verify Migrations
run: |
node verify.js
php verify.php
python manage.py showmigrations
php artisan migrate:status
vendor/bin/phinx status
Quick Reference Summary
| Stack | Migration Tool | Run Command | Status Command | Console |
|---|---|---|---|---|
| Spring Boot | Flyway | Auto on startup | mvn flyway:info | CommandLineRunner |
| NodeJS | Sequelize CLI | db:migrate | db:migrate:status | node verify.js |
| Django | Built-in | manage.py migrate | showmigrations | manage.py shell |
| Flask/FastAPI | Alembic | alembic upgrade head | alembic current | flask shell |
| Laravel | Artisan | artisan migrate | migrate:status | artisan tinker |
| Core PHP | Phinx | phinx migrate | phinx status | php verify.php |
| Flutter (local) | sqflite | App startup | dart verify.dart | dart verify.dart |
Frequently Asked Questions
Why not just use ddl-auto=update in Spring Boot?
It's not versioned, not auditable, has no rollback, and is unsafe for production. Use Flyway + ddl-auto=validate instead.
How do we start if we already have a running app with no migrations?
Create a baseline migration of the current schema, baseline the tool against the existing DB, and proceed with all future changes as migrations only.
Is Django migration the same as Flask/FastAPI?
No. Django has migrations built in. Flask/FastAPI require Alembic to be explicitly configured.
What if two developers add migrations for the same table?
Rebase, resolve conflicts, and create a new sequential migration file if needed.
Won't this replace the DBA completely?
DBAs can still review migration PRs and advise on complex schema changes — but day-to-day changes no longer require manual scripts.
Can Flutter use a remote DB directly?
Not recommended. Flutter should talk to a backend API; the backend manages all DB operations and schema changes.
Resources
Explore Related Tools
Need to format or validate SQL? Try our SQL Formatter or JSON Formatter to keep your migration files clean and readable.