Create and manage database migrations using golang-migrate. Use when creating tables, modifying schemas, adding columns, or managing database structure changes.
This skill guides you through creating and managing database migrations using golang-migrate following this project's specific patterns.
-- 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...
);
VARCHAR(36) for IDs (NOT UUID type!)created_at, updated_at, deleted_at - all TIMESTAMP NULLmake 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
make migration-create name=create_users_table
This creates two files in migration/:
000001_create_users_table.up.sql - Applied when migrating up000001_create_users_table.down.sql - Applied when migrating downThe .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 IDUNIQUE INDEX instead of UNIQUE CONSTRAINTTIMESTAMP NULLDEFAULT gen_random_uuid() - handled by applicationREFERENCES or FOREIGN KEYThe .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.
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;
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;
UP:
-- Use UNIQUE INDEX, NOT UNIQUE CONSTRAINT
CREATE UNIQUE INDEX idx_users_phone ON users(phone);
DOWN:
DROP INDEX IF EXISTS idx_users_phone;
UP:
ALTER TABLE users RENAME COLUMN full_name TO display_name;
DOWN:
ALTER TABLE users RENAME COLUMN display_name TO full_name;
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;
UP:
ALTER TABLE users ALTER COLUMN email TYPE VARCHAR(320);
DOWN:
ALTER TABLE users ALTER COLUMN email TYPE VARCHAR(255);
UP:
ALTER TABLE users ALTER COLUMN status SET DEFAULT 'active';
DOWN:
ALTER TABLE users ALTER COLUMN status DROP DEFAULT;
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;
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;
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;
make migration-up
make migration-up version=2 # Apply next 2 migrations
make migration-down
make migration-down version=2 # Rollback last 2 migrations
make migration-version
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.
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
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;
Once a migration is applied in production:
Since we don't use FK:
-- Product references user
user_id VARCHAR(36) NOT NULL,
-- Add index for queries
CREATE INDEX idx_products_user_id ON products(user_id);
Follow naming convention:
idx_{table}_{column} - Regular indexidx_{table}_{col1}_{col2} - Composite indexFor soft delete queries:
CREATE INDEX idx_users_deleted_at ON users(deleted_at);
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);
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
);
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.
Check .env file for correct database credentials:
DB_HOSTDB_PORTDB_USERDB_PASSDB_NAMEEnsure 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;
If you see "index already exists" error:
IF NOT EXISTS in CREATE INDEX (PostgreSQL 9.5+)DROP INDEX IF EXISTS before creatingAfter 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:
string type, not uuid.UUIDBefore creating migration: