postgresql-migrations-expert
Comprehensive guide to PostgreSQL migrations - common errors, generated columns, full-text search, indexes, idempotent migrations, and best practices for database schema changes
About postgresql-migrations-expert
postgresql-migrations-expert is a Claude AI skill developed by pr-pm. Comprehensive guide to PostgreSQL migrations - common errors, generated columns, full-text search, indexes, idempotent migrations, and best practices for database schema changes This powerful Claude Code plugin helps developers automate workflows and enhance productivity with intelligent AI assistance.
Why use postgresql-migrations-expert? With 38 stars on GitHub, this skill has been trusted by developers worldwide. Install this Claude skill instantly to enhance your development workflow with AI-powered automation.
| name | postgres-migrations |
| description | Comprehensive guide to PostgreSQL migrations - common errors, generated columns, full-text search, indexes, idempotent migrations, and best practices for database schema changes |
PostgreSQL Migrations Skill
Common PostgreSQL Migration Errors and Solutions
1. "Subquery uses ungrouped column from outer query"
Cause: Subquery in SELECT/CASE references columns from outer query that aren't in GROUP BY.
Solution: Use CTE (Common Table Expression) to separate aggregation from subqueries:
-- ❌ Bad - subquery references ungrouped p.id SELECT SPLIT_PART(p.id, '/', 1) as author, COUNT(*) as count, CASE WHEN EXISTS ( SELECT 1 FROM users WHERE username = SPLIT_PART(p.id, '/', 1) ) THEN TRUE ELSE FALSE END as claimed FROM packages p GROUP BY SPLIT_PART(p.id, '/', 1); -- ✅ Good - use CTE to compute aggregates first WITH author_stats AS ( SELECT SPLIT_PART(p.id, '/', 1) as author, COUNT(*) as count FROM packages p GROUP BY SPLIT_PART(p.id, '/', 1) ) SELECT author, count, EXISTS (SELECT 1 FROM users WHERE username = author_stats.author) as claimed FROM author_stats;
2. "Functions in index expression must be marked IMMUTABLE"
Cause: PostgreSQL requires functions in indexes/generated columns to be IMMUTABLE.
Problem Functions:
array_to_string()- marked STABLE, not IMMUTABLEto_char()- depends on timezone/locale settingsnow()- changes over time
Solution: Create IMMUTABLE wrapper functions:
-- Create IMMUTABLE wrapper for array_to_string CREATE OR REPLACE FUNCTION immutable_array_to_string(text[], text) RETURNS text AS $$ SELECT array_to_string($1, $2) $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE; -- Use in generated column ALTER TABLE packages ADD COLUMN search_vector tsvector GENERATED ALWAYS AS ( setweight(to_tsvector('english', coalesce(name, '')), 'A') || setweight(to_tsvector('english', immutable_array_to_string(tags, ' ')), 'B') ) STORED; -- Now you can index it CREATE INDEX idx_search ON packages USING gin(search_vector);
3. "Relation does not exist" (Extensions)
Cause: Extension not installed (e.g., pg_stat_statements, pg_trgm, uuid-ossp).
Solution: Make extension usage optional with error handling:
-- Try to create extension, ignore if unavailable DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_trgm') THEN BEGIN CREATE EXTENSION pg_trgm; EXCEPTION WHEN insufficient_privilege OR feature_not_supported THEN RAISE NOTICE 'pg_trgm extension not available - skipping trigram indexes'; END; END IF; END $$; -- Only create trigram indexes if extension exists DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_trgm') THEN CREATE INDEX idx_name_trgm ON packages USING gin(name gin_trgm_ops); END IF; END $$;
4. Idempotent Migrations
Always use IF (NOT) EXISTS to make migrations re-runnable:
-- Tables CREATE TABLE IF NOT EXISTS users (...); -- Columns ALTER TABLE users ADD COLUMN IF NOT EXISTS email VARCHAR(255); -- Indexes CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); -- Drop operations DROP TABLE IF EXISTS old_table CASCADE; DROP INDEX IF EXISTS old_index; DROP VIEW IF EXISTS old_view CASCADE; DROP FUNCTION IF EXISTS old_function(args); -- Extensions CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
5. Handling Circular Dependencies
Issue: Table A references table B, table B references table A.
Solution: Create tables first without foreign keys, then add constraints:
-- Step 1: Create tables without foreign keys CREATE TABLE users ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), name VARCHAR(255) ); CREATE TABLE posts ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), author_id UUID -- No FK constraint yet ); -- Step 2: Add foreign key constraints ALTER TABLE posts ADD CONSTRAINT fk_posts_author FOREIGN KEY (author_id) REFERENCES users(id);
6. Working with Generated Columns
Rules:
- Must use IMMUTABLE functions only
- Cannot reference other generated columns
- Use STORED (not VIRTUAL in PostgreSQL)
- Cannot be updated directly
-- ✅ Good - IMMUTABLE functions ALTER TABLE packages ADD COLUMN full_name TEXT GENERATED ALWAYS AS (namespace || '/' || name) STORED; -- ✅ Good - with COALESCE for nulls ALTER TABLE packages ADD COLUMN search_text TEXT GENERATED ALWAYS AS ( coalesce(name, '') || ' ' || coalesce(description, '') ) STORED; -- ❌ Bad - NOW() is not immutable ALTER TABLE logs ADD COLUMN year INTEGER GENERATED ALWAYS AS (EXTRACT(YEAR FROM NOW())) STORED; -- ERROR -- ✅ Good - use created_at column instead ALTER TABLE logs ADD COLUMN year INTEGER GENERATED ALWAYS AS (EXTRACT(YEAR FROM created_at)) STORED;
7. Materialized Views
Best Practices:
-- Create with data CREATE MATERIALIZED VIEW IF NOT EXISTS package_rankings AS SELECT id, name, total_downloads, ROW_NUMBER() OVER (ORDER BY total_downloads DESC) as rank FROM packages WHERE visibility = 'public'; -- Create indexes on materialized views CREATE INDEX IF NOT EXISTS idx_rankings_downloads ON package_rankings(total_downloads DESC); -- Refresh function CREATE OR REPLACE FUNCTION refresh_rankings() RETURNS void AS $$ BEGIN REFRESH MATERIALIZED VIEW CONCURRENTLY package_rankings; END; $$ LANGUAGE plpgsql; -- Schedule refresh (requires pg_cron extension) -- SELECT cron.schedule('refresh-rankings', '0 * * * *', 'SELECT refresh_rankings()');
8. Full-Text Search Optimization
Pattern: Use generated column + GIN index for best performance:
-- 1. Create immutable helper CREATE OR REPLACE FUNCTION immutable_array_to_string(text[], text) RETURNS text AS $$ SELECT array_to_string($1, $2) $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE; -- 2. Add generated column ALTER TABLE packages ADD COLUMN search_vector tsvector GENERATED ALWAYS AS ( setweight(to_tsvector('english', coalesce(name, '')), 'A') || setweight(to_tsvector('english', coalesce(description, '')), 'B') || setweight(to_tsvector('english', immutable_array_to_string(tags, ' ')), 'C') ) STORED; -- 3. Create GIN index CREATE INDEX idx_packages_search ON packages USING gin(search_vector); -- 4. Query using the index SELECT * FROM packages WHERE search_vector @@ websearch_to_tsquery('english', 'react hooks');
9. Composite Indexes for Common Queries
Principles:
- Equality filters first, then ranges, then sorts
- Most selective columns first
- Include WHERE clause conditions
-- Query: WHERE type = 'agent' AND category = 'development' ORDER BY downloads DESC CREATE INDEX idx_packages_type_category_downloads ON packages(type, category, total_downloads DESC) WHERE visibility = 'public'; -- Query: WHERE author = 'foo' AND deprecated = FALSE ORDER BY created_at DESC CREATE INDEX idx_packages_author_active ON packages(author_id, created_at DESC) WHERE deprecated = FALSE AND visibility = 'public'; -- Partial index for common filter CREATE INDEX idx_packages_verified ON packages(verified, total_downloads DESC) WHERE verified = TRUE AND visibility = 'public';
10. Migration File Structure
Best Practice Template:
-- Migration XXX: Description -- Brief explanation of what this migration does -- ============================================ -- EXTENSIONS -- ============================================ CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- ============================================ -- TABLES -- ============================================ CREATE TABLE IF NOT EXISTS table_name ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), name VARCHAR(255) NOT NULL, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); -- ============================================ -- INDEXES -- ============================================ CREATE INDEX IF NOT EXISTS idx_table_name ON table_name(name); -- ============================================ -- VIEWS -- ============================================ CREATE OR REPLACE VIEW view_name AS SELECT * FROM table_name WHERE active = true; -- ============================================ -- FUNCTIONS -- ============================================ CREATE OR REPLACE FUNCTION function_name() RETURNS void AS $$ BEGIN -- Function body END; $$ LANGUAGE plpgsql; -- ============================================ -- TRIGGERS -- ============================================ CREATE OR REPLACE FUNCTION update_timestamp() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trigger_update_timestamp BEFORE UPDATE ON table_name FOR EACH ROW EXECUTE FUNCTION update_timestamp(); -- ============================================ -- COMMENTS -- ============================================ COMMENT ON TABLE table_name IS 'Description of table purpose'; COMMENT ON COLUMN table_name.name IS 'Description of column';
Common Patterns
Pattern: Auto-updating Timestamps
CREATE OR REPLACE FUNCTION update_updated_at() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; -- Apply to all tables that need it CREATE TRIGGER trigger_users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_updated_at();
Pattern: Soft Delete
ALTER TABLE packages ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP WITH TIME ZONE; CREATE INDEX IF NOT EXISTS idx_packages_not_deleted ON packages(id) WHERE deleted_at IS NULL; -- View for active records CREATE OR REPLACE VIEW active_packages AS SELECT * FROM packages WHERE deleted_at IS NULL;
Pattern: Enumerated Types
-- Option 1: CHECK constraint (more flexible) ALTER TABLE packages ADD COLUMN status VARCHAR(50) DEFAULT 'active' CHECK (status IN ('active', 'deprecated', 'archived')); -- Option 2: ENUM type (more strict) CREATE TYPE package_status AS ENUM ('active', 'deprecated', 'archived'); ALTER TABLE packages ADD COLUMN status package_status DEFAULT 'active';
Pattern: JSON/JSONB Columns
ALTER TABLE packages ADD COLUMN IF NOT EXISTS metadata JSONB DEFAULT '{}'; -- Index on JSONB keys CREATE INDEX IF NOT EXISTS idx_packages_metadata_tags ON packages USING gin((metadata->'tags')); -- Index on specific JSON path CREATE INDEX IF NOT EXISTS idx_packages_metadata_version ON packages((metadata->>'version'));
Performance Tips
1. ANALYZE After Migrations
-- Update statistics after adding indexes or bulk data ANALYZE packages; ANALYZE VERBOSE packages; -- Show details
2. EXPLAIN Your Queries
-- Check if indexes are being used EXPLAIN ANALYZE SELECT * FROM packages WHERE type = 'agent' ORDER BY downloads DESC LIMIT 10; -- Look for: -- - "Index Scan" (good) vs "Seq Scan" (bad for large tables) -- - High "cost" values -- - Long "execution time"
3. Vacuum After Bulk Changes
-- Clean up dead rows VACUUM ANALYZE packages; -- Full vacuum (locks table) VACUUM FULL packages;
Migration Checklist
- All CREATE statements use IF (NOT) EXISTS
- All DROP statements use IF EXISTS
- All functions in indexes/generated columns are IMMUTABLE
- Foreign keys reference existing tables
- Indexes have meaningful names (idx_table_column pattern)
- Extensions are optional with error handling
- Comments added for complex logic
- Test migration in local/dev before production
- Migration is idempotent (can run multiple times safely)
- Large migrations include progress logging
Testing Migrations Locally
# Run migration npm run migrate # Check for errors docker-compose logs postgres # Rollback if needed (manual) # Connect to DB and DROP objects created by migration # Verify docker-compose exec postgres psql -U prpm -d prpm_registry -c "\d packages" docker-compose exec postgres psql -U prpm -d prpm_registry -c "\di" # List indexes
Resources

pr-pm
prpm
Download Skill Files
View Installation GuideDownload the complete skill directory including SKILL.md and all related files