Create new database migrations for a microservice using Goose format with proper annotations and naming
Use this skill when the user needs to create a new database migration for any microservice.
This project uses Goose for SQL migrations. All migration files are plain SQL with special Goose annotations.
<service>/migrations/<filename>.sql
Format: <sequence_number>_<description>.sql
Examples:
001_init_auth_schema.sql002_add_user_preferences.sql003_create_token_revocations_table.sql20251103191700_init_auth_schema.sql (timestamp-based, also accepted)Rules:
Every migration file MUST have Goose annotations. Without these, migrations will NOT run.
-- +goose Up
-- SQL in this section is executed when the migration is applied.
CREATE TABLE example (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- +goose Down
-- SQL in this section is executed when the migration is rolled back.
DROP TABLE IF EXISTS example;
ls -la <service>/migrations/
Determine the next sequence number and understand the existing schema.
Review the most recent migration to understand current schema state and naming patterns.
Create a new file in <service>/migrations/ following the naming convention.
Standard patterns used in this project:
-- Requires PostgreSQL 13+. For PG 12 or older, enable: CREATE EXTENSION IF NOT EXISTS "pgcrypto";
id UUID PRIMARY KEY DEFAULT gen_random_uuid()
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
deleted_at TIMESTAMP WITH TIME ZONE -- soft delete (nullable)
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE
⚠️ For large tables (10k+ rows): Use
CREATE INDEX CONCURRENTLYto avoid table locks.CONCURRENTLYcannot run inside a transaction — use-- +goose NO TRANSACTIONannotation.
-- Standard (OK for small tables / new tables in same migration)
CREATE INDEX idx_orders_customer_id ON orders(customer_id);
CREATE INDEX idx_orders_status ON orders(status);
CREATE UNIQUE INDEX idx_users_email ON users(email);
For large tables, create a separate migration file:
-- +goose Up
-- +goose NO TRANSACTION
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_customer_id ON orders(customer_id);
-- +goose Down
-- +goose NO TRANSACTION
DROP INDEX CONCURRENTLY IF EXISTS idx_orders_customer_id;
status VARCHAR(50) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'active', 'inactive', 'deleted'))
metadata JSONB DEFAULT '{}'::jsonb
CREATE INDEX idx_order_items_order_product ON order_items(order_id, product_id);
The -- +goose Down section must cleanly reverse everything in -- +goose Up:
CREATE TABLE → DROP TABLE IF EXISTSALTER TABLE ADD COLUMN → ALTER TABLE DROP COLUMN IF EXISTSCREATE INDEX → DROP INDEX IF EXISTSAfter creating the migration, update the corresponding GORM model in:
<service>/internal/data/model/<entity>.go
or
<service>/internal/data/<entity>.go
If new fields require new queries, update:
<service>/internal/biz/<entity>.go → Repository interface
<service>/internal/data/<entity>.go → Repository implementation
-- +goose Up
-- +goose StatementBegin
CREATE TABLE IF NOT EXISTS <table_name> (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Business fields
name VARCHAR(255) NOT NULL,
description TEXT,
status VARCHAR(50) NOT NULL DEFAULT 'active',
-- Metadata
metadata JSONB DEFAULT '{}'::jsonb,
-- Audit fields
created_by UUID,
updated_by UUID,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
deleted_at TIMESTAMP WITH TIME ZONE
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_<table_name>_status ON <table_name>(status);
CREATE INDEX IF NOT EXISTS idx_<table_name>_created_at ON <table_name>(created_at);
CREATE INDEX IF NOT EXISTS idx_<table_name>_deleted_at ON <table_name>(deleted_at) WHERE deleted_at IS NOT NULL;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS <table_name>;
-- +goose StatementEnd
-- +goose Up
ALTER TABLE <table_name> ADD COLUMN IF NOT EXISTS <column_name> <type> <constraints>;
CREATE INDEX IF NOT EXISTS idx_<table_name>_<column_name> ON <table_name>(<column_name>);
-- +goose Down
DROP INDEX IF EXISTS idx_<table_name>_<column_name>;
ALTER TABLE <table_name> DROP COLUMN IF EXISTS <column_name>;
-- +goose Up and -- +goose Down - Migration will fail without theseIF NOT EXISTS / IF EXISTS for idempotency-- +goose StatementBegin / -- +goose StatementEnd for multi-statement blocks or functions/triggersTIMESTAMP WITH TIME ZONE not just TIMESTAMPdeleted_at TIMESTAMP WITH TIME ZONE (nullable)Ref: Coding Standards §3
| Migration Type | Version Bump | Rationale |
|---|---|---|
| New table | MINOR | New feature, backward compatible |
| Add column (nullable/default) | MINOR | Backward compatible |
| Add column (NOT NULL, no default) | MAJOR | May break existing data/queries |
| Remove/rename column | MAJOR | Breaking change for existing code |
| Add index | PATCH | Performance improvement, no behavior change |
| Data migration only | PATCH | Bug fix or data cleanup |
Always update CHANGELOG.md when adding migrations:
## [Unreleased]
### Added
- New migration: add `preferences` column to `users` table
ls migrations/) for sequence number-- +goose Up and -- +goose Down annotations presentIF NOT EXISTS / IF EXISTS for idempotencyTIMESTAMP WITH TIME ZONE (not plain TIMESTAMP)go build ./...)Use this for rapid migration creation: