Database Migrations
This skill guides you through creating and managing database migrations using golang-migrate following this project's specific patterns.
⚠️ IMPORTANT PROJECT PATTERNS
Table Structure Pattern
-- Standard column order for ALL tables:
CREATE TABLE IF NOT EXISTS table_name (
id VARCHAR(36) PRIMARY KEY, -- ID first, VARCHAR(36) not UUID!
created_at TIMESTAMP NULL, -- Timestamp fields
updated_at TIMESTAMP NULL,
deleted_at TIMESTAMP NULL,
-- Then other columns
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
-- etc...
);
Critical Rules
- •✅ Use
VARCHAR(36)for IDs (NOTUUIDtype!) - •✅ ID always PRIMARY KEY, always first column
- •✅ Timestamps:
created_at,updated_at,deleted_at- allTIMESTAMP NULL - •❌ NO Foreign Keys (FK)
- •❌ NO References
- •❌ NO Constraints (except PRIMARY KEY and UNIQUE)
- •❌ NO CASCADE operations
- •✅ Use indexes for relationships instead of FKs
Quick Reference
make migration-create name=xxx # Create new migration make migration-up # Apply all pending migrations make migration-down # Rollback last migration make migration-version # Show current version make migration-force version=N # Force to specific version
Creating New Migrations
1. Create Migration Files
make migration-create name=create_users_table
This creates two files in migration/:
- •
000001_create_users_table.up.sql- Applied when migrating up - •
000001_create_users_table.down.sql- Applied when migrating down
2. Write the UP Migration
The .up.sql file should create or modify database structures:
-- migration/000001_create_users_table.up.sql
-- Standard table structure with correct column order
CREATE TABLE IF NOT EXISTS users (
id VARCHAR(36) PRIMARY KEY,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
deleted_at TIMESTAMP NULL,
email VARCHAR(255) NOT NULL,
username VARCHAR(100) NOT NULL,
password VARCHAR(255) NOT NULL,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
phone VARCHAR(20) NULL,
status VARCHAR(50) NULL DEFAULT 'active',
birth_date DATE NULL,
gender VARCHAR(10) NULL,
role VARCHAR(50) NULL DEFAULT 'user',
provider VARCHAR(50) NULL,
is_active BOOLEAN NULL DEFAULT TRUE
);
-- Create UNIQUE indexes (NO UNIQUE CONSTRAINT!)
CREATE UNIQUE INDEX idx_users_email ON users(email);
CREATE UNIQUE INDEX idx_users_username ON users(username);
-- Regular indexes for frequently queried columns
CREATE INDEX idx_users_status ON users(status);
CREATE INDEX idx_users_role ON users(role);
CREATE INDEX idx_users_deleted_at ON users(deleted_at);
IMPORTANT:
- •✅
VARCHAR(36)for ID - •✅ ID, timestamps first
- •✅ Use
UNIQUE INDEXinstead ofUNIQUE CONSTRAINT - •✅ All timestamps are
TIMESTAMP NULL - •❌ NO
DEFAULT gen_random_uuid()- handled by application - •❌ NO
REFERENCESorFOREIGN KEY
3. Write the DOWN Migration
The .down.sql file must reverse the UP migration:
-- migration/000001_create_users_table.down.sql DROP INDEX IF EXISTS idx_users_deleted_at; DROP INDEX IF EXISTS idx_users_role; DROP INDEX IF EXISTS idx_users_status; DROP INDEX IF EXISTS idx_users_username; DROP INDEX IF EXISTS idx_users_email; DROP TABLE IF EXISTS users;
Important: DOWN migrations should always use IF EXISTS to avoid errors.
Common Migration Patterns
Adding a New Table
UP:
-- Products table with relationship to users (NO FK!)
CREATE TABLE IF NOT EXISTS products (
id VARCHAR(36) PRIMARY KEY,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
deleted_at TIMESTAMP NULL,
name VARCHAR(255) NOT NULL,
description TEXT NULL,
price DECIMAL(10, 2) NOT NULL,
user_id VARCHAR(36) NOT NULL, -- NO FOREIGN KEY!
category_id VARCHAR(36) NULL,
status VARCHAR(50) NULL DEFAULT 'active'
);
-- Index on user_id for queries (replaces FK)
CREATE INDEX idx_products_user_id ON products(user_id);
CREATE INDEX idx_products_category_id ON products(category_id);
CREATE INDEX idx_products_status ON products(status);
CREATE INDEX idx_products_deleted_at ON products(deleted_at);
DOWN:
DROP INDEX IF EXISTS idx_products_deleted_at; DROP INDEX IF EXISTS idx_products_status; DROP INDEX IF EXISTS idx_products_category_id; DROP INDEX IF EXISTS idx_products_user_id; DROP TABLE IF EXISTS products;
Adding a Column
UP:
ALTER TABLE users ADD COLUMN avatar_url VARCHAR(500) NULL; ALTER TABLE users ADD COLUMN bio TEXT NULL;
DOWN:
ALTER TABLE users DROP COLUMN IF EXISTS bio; ALTER TABLE users DROP COLUMN IF EXISTS avatar_url;
Adding a UNIQUE Index
UP:
-- Use UNIQUE INDEX, NOT UNIQUE CONSTRAINT CREATE UNIQUE INDEX idx_users_phone ON users(phone);
DOWN:
DROP INDEX IF EXISTS idx_users_phone;
Renaming a Column
UP:
ALTER TABLE users RENAME COLUMN full_name TO display_name;
DOWN:
ALTER TABLE users RENAME COLUMN display_name TO full_name;
Adding an Enum Type
UP:
-- Create enum type
CREATE TYPE user_role AS ENUM ('admin', 'user', 'guest');
-- Add column using enum
ALTER TABLE users ADD COLUMN role user_role DEFAULT 'user';
DOWN:
ALTER TABLE users DROP COLUMN IF EXISTS role; DROP TYPE IF EXISTS user_role;
Modifying Column Type
UP:
ALTER TABLE users ALTER COLUMN email TYPE VARCHAR(320);
DOWN:
ALTER TABLE users ALTER COLUMN email TYPE VARCHAR(255);
Adding Default Value
UP:
ALTER TABLE users ALTER COLUMN status SET DEFAULT 'active';
DOWN:
ALTER TABLE users ALTER COLUMN status DROP DEFAULT;
Creating Junction Table (Many-to-Many) - NO FK!
UP:
-- Junction table for user-role relationship
CREATE TABLE IF NOT EXISTS user_roles (
id VARCHAR(36) PRIMARY KEY,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
deleted_at TIMESTAMP NULL,
user_id VARCHAR(36) NOT NULL, -- NO FOREIGN KEY!
role_id VARCHAR(36) NOT NULL, -- NO FOREIGN KEY!
assigned_at TIMESTAMP NULL
);
-- Composite unique index to prevent duplicates
CREATE UNIQUE INDEX idx_user_roles_user_role ON user_roles(user_id, role_id);
-- Individual indexes for queries
CREATE INDEX idx_user_roles_user_id ON user_roles(user_id);
CREATE INDEX idx_user_roles_role_id ON user_roles(role_id);
CREATE INDEX idx_user_roles_deleted_at ON user_roles(deleted_at);
DOWN:
DROP INDEX IF EXISTS idx_user_roles_deleted_at; DROP INDEX IF EXISTS idx_user_roles_role_id; DROP INDEX IF EXISTS idx_user_roles_user_id; DROP INDEX IF EXISTS idx_user_roles_user_role; DROP TABLE IF EXISTS user_roles;
Table with Self-Reference (NO FK!)
UP:
-- Categories with parent-child relationship (NO FK!)
CREATE TABLE IF NOT EXISTS categories (
id VARCHAR(36) PRIMARY KEY,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
deleted_at TIMESTAMP NULL,
name VARCHAR(255) NOT NULL,
parent_id VARCHAR(36) NULL, -- NO FOREIGN KEY to self!
slug VARCHAR(255) NOT NULL,
description TEXT NULL
);
-- Index on parent_id for hierarchy queries
CREATE INDEX idx_categories_parent_id ON categories(parent_id);
CREATE UNIQUE INDEX idx_categories_slug ON categories(slug);
CREATE INDEX idx_categories_deleted_at ON categories(deleted_at);
DOWN:
DROP INDEX IF EXISTS idx_categories_deleted_at; DROP INDEX IF EXISTS idx_categories_slug; DROP INDEX IF EXISTS idx_categories_parent_id; DROP TABLE IF EXISTS categories;
Complete Example: Orders and Order Items
UP Migration:
-- Orders table
CREATE TABLE IF NOT EXISTS orders (
id VARCHAR(36) PRIMARY KEY,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
deleted_at TIMESTAMP NULL,
user_id VARCHAR(36) NOT NULL, -- NO FK!
order_number VARCHAR(50) NOT NULL,
total_amount DECIMAL(10, 2) NOT NULL,
status VARCHAR(50) NULL DEFAULT 'pending',
notes TEXT NULL
);
CREATE UNIQUE INDEX idx_orders_order_number ON orders(order_number);
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_status ON orders(status);
CREATE INDEX idx_orders_deleted_at ON orders(deleted_at);
-- Order items table
CREATE TABLE IF NOT EXISTS order_items (
id VARCHAR(36) PRIMARY KEY,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
deleted_at TIMESTAMP NULL,
order_id VARCHAR(36) NOT NULL, -- NO FK!
product_id VARCHAR(36) NOT NULL, -- NO FK!
quantity INT NOT NULL,
price DECIMAL(10, 2) NOT NULL,
subtotal DECIMAL(10, 2) NOT NULL
);
CREATE INDEX idx_order_items_order_id ON order_items(order_id);
CREATE INDEX idx_order_items_product_id ON order_items(product_id);
CREATE INDEX idx_order_items_deleted_at ON order_items(deleted_at);
DOWN Migration:
DROP INDEX IF EXISTS idx_order_items_deleted_at; DROP INDEX IF EXISTS idx_order_items_product_id; DROP INDEX IF EXISTS idx_order_items_order_id; DROP TABLE IF EXISTS order_items; DROP INDEX IF EXISTS idx_orders_deleted_at; DROP INDEX IF EXISTS idx_orders_status; DROP INDEX IF EXISTS idx_orders_user_id; DROP INDEX IF EXISTS idx_orders_order_number; DROP TABLE IF EXISTS orders;
Running Migrations
Apply All Pending Migrations
make migration-up
Apply Specific Number of Migrations
make migration-up version=2 # Apply next 2 migrations
Rollback Last Migration
make migration-down
Rollback Specific Number of Migrations
make migration-down version=2 # Rollback last 2 migrations
Check Current Version
make migration-version
Force to Specific Version (Recovery)
If migrations are in a bad state:
make migration-force version=3 # Force to version 3
Warning: Only use migration-force for recovery. It doesn't run migrations.
Best Practices
1. Always Test Migrations
Test both UP and DOWN migrations:
# Apply migration make migration-up # Verify database state # Check tables, columns, indexes # Test rollback make migration-down # Verify clean rollback # Confirm everything was removed
2. Make Migrations Idempotent
Always use IF EXISTS and IF NOT EXISTS:
-- Good CREATE TABLE IF NOT EXISTS users (...); DROP TABLE IF EXISTS users; DROP INDEX IF EXISTS idx_users_email; -- Bad (will fail if already exists/doesn't exist) CREATE TABLE users (...); DROP TABLE users; DROP INDEX idx_users_email;
3. Never Edit Applied Migrations
Once a migration is applied in production:
- •Never modify it
- •Create a new migration to make changes
- •This ensures version history is consistent
4. Use Indexes Instead of Foreign Keys
Since we don't use FK:
- •Always add indexes on columns used in joins
- •Index columns that reference other tables
- •This maintains query performance
-- Product references user user_id VARCHAR(36) NOT NULL, -- Add index for queries CREATE INDEX idx_products_user_id ON products(user_id);
5. Standard Index Naming
Follow naming convention:
- •
idx_{table}_{column}- Regular index - •
idx_{table}_{col1}_{col2}- Composite index
6. Always Index deleted_at
For soft delete queries:
CREATE INDEX idx_users_deleted_at ON users(deleted_at);
7. Document Complex Migrations
Add comments to explain non-obvious changes:
-- This migration adds soft delete support -- We keep deleted records for audit purposes ALTER TABLE users ADD COLUMN deleted_at TIMESTAMP NULL; CREATE INDEX idx_users_deleted_at ON users(deleted_at);
Column Order Template
Always follow this order:
CREATE TABLE IF NOT EXISTS table_name (
-- 1. ID (always first)
id VARCHAR(36) PRIMARY KEY,
-- 2. Timestamp columns (always these three)
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
deleted_at TIMESTAMP NULL,
-- 3. Required business columns
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
-- 4. Reference columns (NO FK!)
user_id VARCHAR(36) NOT NULL,
category_id VARCHAR(36) NULL,
-- 5. Optional business columns
description TEXT NULL,
status VARCHAR(50) NULL DEFAULT 'active',
-- 6. Boolean flags
is_active BOOLEAN NULL DEFAULT TRUE,
is_verified BOOLEAN NULL DEFAULT FALSE
);
Troubleshooting
Migration Failed Midway
Check current version:
make migration-version
If stuck in "dirty" state, force to last good version:
make migration-force version=N
Then fix the problematic migration and retry.
Can't Connect to Database
Check .env file for correct database credentials:
- •
DB_HOST - •
DB_PORT - •
DB_USER - •
DB_PASS - •
DB_NAME
Permission Errors
Ensure database user has sufficient privileges:
GRANT ALL PRIVILEGES ON DATABASE db_name TO your_user; GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO your_user;
Index Already Exists
If you see "index already exists" error:
- •Use
IF NOT EXISTSin CREATE INDEX (PostgreSQL 9.5+) - •Or use
DROP INDEX IF EXISTSbefore creating
Integration with GORM
After creating migrations, update GORM entities in internal/shared/domain/entity/:
type User struct {
ID string `json:"id" gorm:"type:varchar(36);primaryKey"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
Email string `json:"email" gorm:"type:varchar(255);uniqueIndex;not null"`
Username string `json:"username" gorm:"type:varchar(100);uniqueIndex;not null"`
Password string `json:"-" gorm:"type:varchar(255);not null"`
}
Important:
- •GORM struct tags should match your migration schema
- •Migrations are the source of truth
- •Use migrations, NOT GORM AutoMigrate
- •ID is
stringtype, notuuid.UUID
Migration Checklist
Before creating migration:
- • ID is VARCHAR(36) PRIMARY KEY (first column)
- • Timestamps: created_at, updated_at, deleted_at (TIMESTAMP NULL)
- • NO Foreign Keys, References, Constraints (except PK, UNIQUE)
- • Use indexes for relationships
- • UNIQUE INDEX instead of UNIQUE CONSTRAINT
- • Index on deleted_at for soft deletes
- • IF EXISTS / IF NOT EXISTS for idempotency
- • DOWN migration reverses UP completely
- • Tested UP and DOWN before committing