How Geni uses transactions to ensure migration safety and the transaction:no directive
Geni runs all migrations within database transactions by default, ensuring that migrations either complete fully or are rolled back entirely if an error occurs. This prevents partial migrations from leaving your database in an inconsistent state.
When you run a migration, Geni automatically wraps it in a transaction:
-- Your migration file: 1234567890_create_users.up.sqlCREATE TABLE users ( id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL);CREATE INDEX idx_users_name ON users(name);
Geni executes this as:
BEGIN; CREATE TABLE users ( id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL ); CREATE INDEX idx_users_name ON users(name);COMMIT;
If any statement fails, the entire transaction is rolled back, leaving your database unchanged.
Transactions ensure atomicity - either all statements in a migration succeed, or none of them do. This is critical for maintaining database consistency.
Creating indexes concurrently locks the table less but cannot run in a transaction:
-- transaction:noCREATE INDEX CONCURRENTLY idx_products_name ON products(name);CREATE INDEX CONCURRENTLY idx_products_category ON products(category_id);
Without CONCURRENTLY, index creation locks the table for writes. With CONCURRENTLY, the index builds without blocking writes, but requires running outside a transaction.
-- 1709123456_add_user_roles.up.sqlCREATE TABLE user_roles ( id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id), role VARCHAR(50) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);CREATE INDEX idx_user_roles_user_id ON user_roles(user_id);INSERT INTO user_roles (user_id, role)SELECT id, 'user' FROM users WHERE role IS NULL;
This runs in a transaction - if the INSERT fails, the table and index are rolled back.
-- transaction:noCREATE INDEX CONCURRENTLY idx_orders_user_id ON orders(user_id);CREATE INDEX CONCURRENTLY idx_orders_created_at ON orders(created_at);CREATE INDEX CONCURRENTLY idx_orders_status ON orders(status);
Runs without a transaction to allow concurrent index creation.
-- transaction:no-- Add new enum value (requires no transaction in PostgreSQL)ALTER TYPE order_status ADD VALUE 'refunded';-- Then use itUPDATE orders SET status = 'refunded' WHERE refund_processed = true;
Risks of Disabling TransactionsWhen using transaction:no:
If the migration fails partway through, you’ll have partial changes
You may need to manually fix the database state
Consider breaking complex migrations into smaller, safer pieces
# Test in a staging environmentDATABASE_URL="postgres://staging/myapp" geni up# If it fails, you may need to manually clean up# Fix the migration and try again
-- transaction:no-- Required: CREATE INDEX CONCURRENTLY cannot run inside a transaction block-- This allows the table to remain writable during index creationCREATE INDEX CONCURRENTLY idx_large_table_column ON large_table(column);
Instead of one large migration without transactions:
-- Avoid: One large non-transactional migration-- transaction:noCREATE INDEX CONCURRENTLY idx_a ON table_a(col);CREATE INDEX CONCURRENTLY idx_b ON table_b(col);CREATE INDEX CONCURRENTLY idx_c ON table_c(col);ALTER TYPE status ADD VALUE 'new_status';
Create multiple smaller migrations:
geni new create_index_table_a # transaction:no for concurrent indexgeni new create_index_table_b # transaction:no for concurrent index geni new create_index_table_c # transaction:no for concurrent indexgeni new add_new_status_value # transaction:no for enum change
You can verify transaction behavior by deliberately introducing an error:
-- Test with transaction (default)CREATE TABLE test_table (id SERIAL PRIMARY KEY);INSERT INTO test_table (id) VALUES (1);THIS_WILL_CAUSE_AN_ERROR;-- After migration fails, check: table should NOT exist
-- transaction:no-- Test without transactionCREATE TABLE test_table (id SERIAL PRIMARY KEY);INSERT INTO test_table (id) VALUES (1);THIS_WILL_CAUSE_AN_ERROR;-- After migration fails, check: table WILL exist with one row
Why Do Transactions Matter for Migrations?
Without transactions, a failed migration could leave your database in an inconsistent state:
With Transaction: Migration fails → entire migration rolls back → database unchanged → fix and retry
Without Transaction: Migration fails → partial changes remain → manual cleanup required → risk of data corruption
Transactions provide a safety net, especially in production environments where database consistency is critical.
# Migration fails - no problem, database is unchangedgeni up# Fix the migration filevim migrations/1234567890_create_users.up.sql# Try again - clean slategeni up
# Test concurrent index creation on production-sized dataDATABASE_URL="$STAGING_URL" geni up# Monitor performance and locking# Verify the migration completes successfully# Then deploy to production with confidence