Database Schema Migrations & Seeders Best Practices

Managing database schema changes manually is slow, error-prone, and blocks CI/CD workflows. This guide covers migration and seeder best practices across Spring Boot, NodeJS, Python, Laravel, Core PHP, and Flutter — with real code examples for every stack.

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/trackedNoYes
Code reviewedNoYes
Rollback supportNoYes
Data seedingNoYes
CI/CD safeRiskyYes
Audit trailNoYes
Complex changes (rename, index)LimitedFull control
Safe for productionNoYes

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":

ToolCommand
Flyway./mvnw flyway:baseline
SequelizeMark first migration manually in SequelizeMeta
Djangopython manage.py migrate --fake-initial
Laravelphp artisan migrate --pretend then mark manually
Phinxvendor/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

StackHow Idempotency is Handled
Spring Boot + Flywayflyway_schema_history table tracks applied scripts
NodeJS + SequelizeSequelizeMeta table tracks applied migrations
Djangodjango_migrations table tracks applied migrations
Flask + Alembicalembic_version table tracks current version
Laravelmigrations table tracks applied migrations
Phinxphinxlog 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 BootFlywayAuto on startupmvn flyway:infoCommandLineRunner
NodeJSSequelize CLIdb:migratedb:migrate:statusnode verify.js
DjangoBuilt-inmanage.py migrateshowmigrationsmanage.py shell
Flask/FastAPIAlembicalembic upgrade headalembic currentflask shell
LaravelArtisanartisan migratemigrate:statusartisan tinker
Core PHPPhinxphinx migratephinx statusphp verify.php
Flutter (local)sqfliteApp startupdart verify.dartdart 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.

About the Author: The DevTools Online team consists of experienced web developers passionate about creating useful tools and sharing knowledge with the developer community.