Implementation: Database Migrations
When to Use
Use this skill when:
- •Creating initial database schema from design spec
- •Adding new tables, columns, or indexes
- •Managing schema changes for single or multi-database systems
- •Setting up migration file structure
Prerequisites:
- •✅ Database schema designed (see: design-lokstra-schema-design)
- •✅ Domain models finalized
- •✅ Database service configured in config.yaml (see: implementation-lokstra-yaml-config)
- •✅ Database pool defined in
service-definitionssection
Quick Start: Create Migration
# Create new migration (auto-versioned) lokstra migration create create_users_table # Output: # ✅ Created migration files: # migrations/001_create_users_table.up.sql # migrations/001_create_users_table.down.sql
This creates properly numbered migration files. Edit them with your SQL, then run:
lokstra migration up
Migration File Structure
Option 1: Flat Structure (Single Database)
Recommended for most applications with one database:
migrations/ ├── migration.yaml # Optional: DB pool config ├── 001_create_users.up.sql ├── 001_create_users.down.sql ├── 002_add_email_verified.up.sql ├── 002_add_email_verified.down.sql ├── 003_create_orders.up.sql └── 003_create_orders.down.sql
Option 2: Subfolder Structure (Multi-Database)
For systems with multiple databases (main-db, tenant-db, analytics-db):
migrations/
├── 01_main-db/ # Runs first (alphabetical order)
│ ├── migration.yaml # REQUIRED for subfolder detection
│ ├── 001_create_users.up.sql
│ └── 001_create_users.down.sql
├── 02_tenant-db/ # Runs second
│ ├── migration.yaml # REQUIRED
│ ├── 001_create_tenants.up.sql
│ └── 001_create_tenants.down.sql
└── 03_analytics-db/ # Runs third
├── migration.yaml # REQUIRED
└── 001_create_events.up.sql
Important:
- •Each subfolder MUST contain
migration.yamlto be detected as a migration folder. - •Use numeric prefixes (01_, 02_, 03_) for explicit execution order.
- •Subfolders without
migration.yamlare ignored.
migration.yaml Configuration
Fields (matches lokstra_init.MigrationYamlConfig)
# Database pool name from config.yaml service-definitions # REQUIRED for multi-database, recommended for single database dbpool-name: db_main # Schema migrations tracking table name # Default: schema_migrations schema-table: schema_migrations # Enable/disable this migration folder # Set to false to skip without deleting files (useful for maintenance) # Default: true (nil = enabled) enabled: true # Description for documentation description: Main application database
Example: Single Database
File: migrations/migration.yaml
dbpool-name: db_main description: Application database migrations
Example: Multi-Database Setup
File: migrations/01_main-db/migration.yaml
dbpool-name: db_main schema-table: schema_migrations description: Main application database with users and orders
File: migrations/02_analytics-db/migration.yaml
dbpool-name: db_analytics schema-table: analytics_migrations description: Analytics and reporting database
File: migrations/03_ledger-db/migration.yaml
dbpool-name: db_ledger schema-table: ledger_migrations enabled: false # Disabled - use CLI manually for critical ledger DB description: General ledger (manual migrations only)
Naming Convention
{version}_{description}.{up|down}.sql
Rules:
- version: 3-digit zero-padded number (001, 002, 003)
- description: lowercase snake_case (letters, numbers, underscores only)
- direction: up or down
Valid Examples:
001_create_users.up.sql ✅ 001_create_users.down.sql ✅ 002_add_email_verified.up.sql ✅ 015_create_audit_logs.up.sql ✅
Conflict Detection (will error):
001_create_users.up.sql ❌ Conflict! 001_create_orders.up.sql ❌ Same version (001), different description
Lokstra enforces: One version = One description. If you need multiple changes, use separate version numbers.
Lokstra CLI Commands
Create New Migration
# Create in default directory (migrations/) lokstra migration create create_users_table # Create in specific directory lokstra migration create add_orders -dir migrations/01_main-db
Output:
✅ Created migration files: migrations/001_create_users_table.up.sql migrations/001_create_users_table.down.sql 📝 Next steps: 1. Edit the migration files with your SQL 2. Run: lokstra migration up
Run Migrations (Up)
# Run all pending migrations (uses migration.yaml if exists) lokstra migration up # Run in specific directory lokstra migration up -dir migrations/01_main-db # Specify database pool explicitly (overrides migration.yaml) lokstra migration up -dir migrations -db db_main
Rollback Migrations (Down)
# Rollback last migration lokstra migration down # Rollback N migrations lokstra migration down -steps 3 # Rollback specific folder lokstra migration down -dir migrations/01_main-db -steps 1
Check Status
lokstra migration status # Output: # Migration Status: # ================= # [✓] 001: create_users # [✓] 002: add_email_verified # [ ] 003: create_orders # # Total: 3 migrations, 2 applied, 1 pending
Get Current Version
lokstra migration version # Output: Current version: 002 # (or "No migrations applied yet")
Database Pool Resolution Order
- •If
-db <name>flag specified → use it - •Else if
migration.yamlexists withdbpool-name→ use it - •Else → error (database pool must be specified)
Programmatic Migration (main.go)
Single Database (Auto-Migrate on Startup)
package main
import (
"github.com/primadi/lokstra/lokstra_init"
"github.com/primadi/lokstra/lokstra_registry"
)
func main() {
// Bootstrap loads config and initializes services
lokstra_init.Bootstrap()
// Run migrations from default directory (migrations/)
// Uses migration.yaml if present
if err := lokstra_init.CheckDbMigration(&lokstra_init.MigrationConfig{
MigrationsDir: "migrations",
DbPoolName: "db_main", // Optional if migration.yaml has dbpool-name
}); err != nil {
panic(err)
}
// Start server
lokstra_registry.RunServerFromConfig()
}
Multi-Database (Auto-Scan All Subfolders)
package main
import (
"github.com/primadi/lokstra/lokstra_init"
"github.com/primadi/lokstra/lokstra_registry"
)
func main() {
lokstra_init.Bootstrap()
// Auto-scan and run all database migrations
// Scans for subfolders with migration.yaml, runs in alphabetical order
if err := lokstra_init.CheckDbMigrationsAuto("migrations"); err != nil {
panic(err)
}
lokstra_registry.RunServerFromConfig()
}
CheckDbMigrationsAuto behavior:
- •Scans
migrations/for subfolders containingmigration.yaml - •Runs them in alphabetical order (use numeric prefixes: 01_, 02_, 03_)
- •Skips folders with
enabled: false - •Reports success/error/skipped counts
- •If no subfolders found, treats root as single migration folder
Manual Control (Per Database)
package main
import (
"github.com/primadi/lokstra/lokstra_init"
"github.com/primadi/lokstra/lokstra_registry"
)
func main() {
lokstra_init.Bootstrap()
// Run migrations for each database manually (for specific ordering/control)
lokstra_init.CheckDbMigration(&lokstra_init.MigrationConfig{
MigrationsDir: "migrations/01_tenant-db",
})
lokstra_init.CheckDbMigration(&lokstra_init.MigrationConfig{
MigrationsDir: "migrations/02_main-db",
})
lokstra_init.CheckDbMigration(&lokstra_init.MigrationConfig{
MigrationsDir: "migrations/03_analytics-db",
})
lokstra_registry.RunServerFromConfig()
}
Basic Migration Examples
Create Table
File: migrations/001_create_users.up.sql
-- Migration: Create users table
-- Created: 2026-02-01
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY, -- ULID recommended
tenant_id TEXT NOT NULL, -- Multi-tenant (if needed)
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ -- Soft delete
);
-- Indexes
CREATE INDEX idx_users_tenant ON users(tenant_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_users_email ON users(tenant_id, email) WHERE deleted_at IS NULL;
CREATE INDEX idx_users_created_at ON users(tenant_id, created_at DESC);
-- Unique constraint scoped to tenant
CREATE UNIQUE INDEX uq_users_tenant_email
ON users(tenant_id, email)
WHERE deleted_at IS NULL;
File: migrations/001_create_users.down.sql
-- Rollback: Drop users table DROP INDEX IF EXISTS uq_users_tenant_email; DROP INDEX IF EXISTS idx_users_created_at; DROP INDEX IF EXISTS idx_users_email; DROP INDEX IF EXISTS idx_users_tenant; DROP TABLE IF EXISTS users;
Add Column
File: migrations/002_add_email_verified.up.sql
-- Add new column with default value
ALTER TABLE users
ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT FALSE;
-- Partial index for filtering verified users
CREATE INDEX idx_users_verified
ON users(tenant_id, email_verified)
WHERE email_verified = TRUE AND deleted_at IS NULL;
File: migrations/002_add_email_verified.down.sql
-- Rollback: Drop column DROP INDEX IF EXISTS idx_users_verified; ALTER TABLE users DROP COLUMN IF EXISTS email_verified;
Create Related Table with Foreign Key
File: migrations/003_create_orders.up.sql
CREATE TABLE IF NOT EXISTS orders (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
user_id TEXT NOT NULL,
total_amount NUMERIC(12, 2) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'confirmed', 'shipped', 'delivered', 'cancelled')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Composite foreign key (includes tenant_id for multi-tenant isolation)
CONSTRAINT fk_orders_user
FOREIGN KEY (tenant_id, user_id)
REFERENCES users(tenant_id, id)
ON DELETE RESTRICT
);
-- Indexes
CREATE INDEX idx_orders_tenant ON orders(tenant_id);
CREATE INDEX idx_orders_user ON orders(tenant_id, user_id);
CREATE INDEX idx_orders_status ON orders(tenant_id, status);
CREATE INDEX idx_orders_created_at ON orders(tenant_id, created_at DESC);
File: migrations/003_create_orders.down.sql
DROP INDEX IF EXISTS idx_orders_created_at; DROP INDEX IF EXISTS idx_orders_status; DROP INDEX IF EXISTS idx_orders_user; DROP INDEX IF EXISTS idx_orders_tenant; DROP TABLE IF EXISTS orders CASCADE;
Shared Utilities Migration
File: migrations/000_init_utilities.up.sql
-- Create lokstra_core schema (for internal tracking)
CREATE SCHEMA IF NOT EXISTS lokstra_core;
-- Enable UUID extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Create updated_at trigger function (reusable)
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
File: migrations/000_init_utilities.down.sql
-- Rollback utilities (be careful - may break other tables) DROP FUNCTION IF EXISTS update_updated_at_column() CASCADE; -- DROP EXTENSION IF EXISTS "uuid-ossp"; -- Usually keep this -- DROP SCHEMA IF EXISTS lokstra_core CASCADE; -- Never drop if tracking table exists
Add Trigger to Table
File: migrations/004_add_users_updated_trigger.up.sql
-- Attach updated_at trigger to users table
CREATE TRIGGER trg_users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
File: migrations/004_add_users_updated_trigger.down.sql
DROP TRIGGER IF EXISTS trg_users_updated_at ON users;
Data Migration (Backfill)
File: migrations/005_backfill_email_verified.up.sql
-- Backfill: Set email_verified = true for old users -- Use batching for large tables to avoid lock issues UPDATE users SET email_verified = TRUE WHERE created_at < NOW() - INTERVAL '30 days' AND email_verified = FALSE;
File: migrations/005_backfill_email_verified.down.sql
-- Rollback: This is not reversible without data loss
-- Consider: Do nothing, or reset to false if acceptable
-- UPDATE users
-- SET email_verified = FALSE
-- WHERE created_at < NOW() - INTERVAL '30 days';
-- Better: Log a warning
DO $$
BEGIN
RAISE NOTICE 'Backfill rollback: No action taken. Data changes are not reversible.';
END $$;
Best Practices
1. Use IF NOT EXISTS / IF EXISTS
-- Safe to run multiple times (idempotent) CREATE TABLE IF NOT EXISTS users (...); CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); DROP TABLE IF EXISTS old_table; DROP INDEX IF EXISTS old_index;
2. Always Include Audit Columns
CREATE TABLE users (
id TEXT PRIMARY KEY,
-- ... business columns
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ -- Soft delete support
);
3. One Change Per Migration
✅ 001_create_users.up.sql -- Only creates users table ✅ 002_add_email_verified.up.sql -- Only adds one column ✅ 003_create_orders.up.sql -- Only creates orders table ❌ 001_create_users_and_orders.up.sql -- Too many changes
4. Test Rollback Before Commit
# Apply migration lokstra migration up # Verify schema lokstra migration status # Test rollback lokstra migration down -steps 1 # Verify clean rollback lokstra migration status # Re-apply if good lokstra migration up
5. Keep Migrations Small
- •Easier to troubleshoot failures
- •Easier to rollback partial changes
- •Clearer change history
- •Faster to apply in CI/CD
6. Include tenant_id for Multi-Tenant Apps
-- Every business table needs tenant_id
CREATE TABLE products (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL, -- ← MANDATORY
name VARCHAR(255) NOT NULL,
-- ...
);
-- Indexes should include tenant_id first
CREATE INDEX idx_products_tenant_name
ON products(tenant_id, name);
7. Use Composite Foreign Keys for Multi-Tenant
-- Prevents cross-tenant data references
CONSTRAINT fk_orders_user
FOREIGN KEY (tenant_id, user_id)
REFERENCES users(tenant_id, id)
Production Recommendations
Never Auto-Migrate in Production
# migrations/migration.yaml for production dbpool-name: db_prod enabled: false # ← Disable auto-migration description: Production database - use CLI only
Use CLI for Production Deployments
# In CI/CD pipeline or deployment script lokstra migration status -dir migrations -db db_prod # Review pending migrations before applying lokstra migration up -dir migrations -db db_prod
Backup Before Migration
# Create backup before migrating pg_dump -h localhost -U postgres -d myapp > backup_before_migration.sql # Then apply migrations lokstra migration up
Multi-Database Example (E-Commerce)
migrations/
├── 01_main-db/ # Core application
│ ├── migration.yaml # dbpool-name: db_main
│ ├── 001_create_users.up.sql
│ └── 002_create_products.up.sql
│
├── 02_orders-db/ # Orders & inventory
│ ├── migration.yaml # dbpool-name: db_orders
│ ├── 001_create_orders.up.sql
│ └── 002_create_inventory.up.sql
│
└── 03_analytics-db/ # Reporting
├── migration.yaml # dbpool-name: db_analytics
└── 001_create_events.up.sql
Run all with:
lokstra_init.CheckDbMigrationsAuto("migrations")
Or individually:
lokstra migration up -dir migrations/01_main-db lokstra migration up -dir migrations/02_orders-db lokstra migration up -dir migrations/03_analytics-db
Troubleshooting
Error: "database pool not found"
Check that your config.yaml has the database pool defined:
service-definitions:
db_main:
type: dbpool
driver: postgres
dsn: "postgres://user:pass@localhost:5432/myapp?sslmode=disable"
Error: "migrations directory not found"
Ensure the migrations directory exists:
mkdir -p migrations
Error: "migration conflict detected"
You have two migration files with the same version number but different descriptions. Rename one to use a different version:
# Before (conflict): 001_create_users.up.sql 001_create_orders.up.sql # ← Conflict! # After (fixed): 001_create_users.up.sql 002_create_orders.up.sql # ← Use next version
Error: "no UP SQL for migration"
Every version needs both .up.sql and .down.sql files. Create the missing file:
lokstra migration create my_migration_name # This creates both files automatically
Next Steps
- •Create @Handler endpoints (see: implementation-lokstra-create-handler)
- •Create @Service repositories (see: implementation-lokstra-create-service)
- •Write integration tests (see: advanced-lokstra-tests)
Related Skills
- •design-lokstra-schema-design - Schema planning with multi-tenant patterns
- •implementation-lokstra-yaml-config - Database pool configuration
- •implementation-lokstra-create-handler - HTTP endpoints
- •implementation-lokstra-create-service - Data access layer