Guide: Organizing PostgreSQL Migrations and Rollbacks for Small Teams

By · Published on · Updated on · 9 min read

Guide: organizing migrations and rollbacks in PostgreSQL for small teams
Photo: Christina Morillo / Pexels

This guide shows how to organize PostgreSQL migrations and rollbacks for small teams with lightweight, predictable processes. You’ll find core principles, a minimal directory layout, workflow suggestions, reversal strategies and a CI/CD checklist. The focus is practical for teams without a dedicated DBA that still need safe changes.

Why small teams struggle with migrations (and how to fix it)

In small teams the same person often writes code, reviews it and deploys. Ad‑hoc migrations become a risk: branch conflicts, changes that break production, and no clear rollback.

The common symptoms are easy to spot and point to the need for a simple but disciplined process. The good news: you can organize this with a few conventions and a handful of well‑designed scripts.

If you want to expand the foundations beyond PostgreSQL, see our agile database versioning guide and connect those ideas to this practical process.

  • Migrations that work in dev but fail in staging/production.
  • Deploys that need long maintenance windows due to locks and reindexing.
  • No rollback or risky manual fixes required.
  • Branches producing migration files in conflicting order.
  • Scripts without idempotency and no tracking of what ran.

Core principles: atomicity, idempotency and reversibility

Three principles guide the process: (1) atomicity via transactions; (2) idempotency whenever possible; (3) planned reversibility. Add visibility (a log of what ran) and safety parameters (timeouts and locks) and you remove the most common production risks.

Use transactions for DDL when it’s safe in PostgreSQL. Set local timeouts to avoid blocking the database. Also use a logical lock to prevent two migrations running concurrently against the same database.

  • Atomicity: run DDL/small DML inside a transaction.
  • Idempotency: protections like IF NOT EXISTS and prechecks.
  • Reversibility: keep a simple, testable down.sql.
  • Safety: SET LOCAL statement_timeout/lock_timeout.
  • Visibility: a schema_migrations table for audit.
-- 20241003_add_status_to_orders_up.sql
BEGIN;
SET LOCAL lock_timeout = '5s';
SET LOCAL statement_timeout = '30s';
-- Evita concorrência entre execuções de migrations
SELECT pg_advisory_xact_lock(987654321);

-- Exemplo de mudança segura
ALTER TABLE public.orders
  ADD COLUMN IF NOT EXISTS status text NOT NULL DEFAULT 'pending';
COMMENT ON COLUMN public.orders.status IS 'Estado do pedido';

COMMIT;

-- 20241003_add_status_to_orders_down.sql
BEGIN;
SET LOCAL lock_timeout = '5s';
SET LOCAL statement_timeout = '30s';
SELECT pg_advisory_xact_lock(987654321);

-- Reversão simples (cuidado com dados que dependam da coluna)
ALTER TABLE public.orders DROP COLUMN IF EXISTS status;

COMMIT;

Minimal directory structure and naming conventions

Standardize filenames and locations. This helps diffs, reviews and automation. Prefer timestamp + short descriptive slug. Separate up.sql and down.sql. Keep utility scripts in a separate folder.

Also create a schema_migrations table to record applied versions. That prevents reapplying scripts and provides traceability.

  • Prefix: YYYYMMDD-HHMMSS_slug.
  • Separation: one up/down pair per atomic change.
  • Avoid giant files: split by steps if necessary.
  • Put clear comments at the top of files.
# Estrutura sugerida
.
├── db/
│   ├── migrations/
│   │   ├── 20241003-120000_add_status_up.sql
│   │   ├── 20241003-120000_add_status_down.sql
│   │   ├── 20241004-093000_add_idx_orders_created_at_up.sql
│   │   └── 20241004-093000_add_idx_orders_created_at_down.sql
│   └── scripts/
│       ├── migrate.sh
│       └── rollback.sh
└── .env

Lightweight workflow: branches, PRs and staging before merge

Organize the workflow to reduce conflicts and validate rollbacks early. The goal is for every PR to include its migrations, run them automatically in staging, and provide quick, clear validation.

To avoid ordering conflicts, generate the timestamp when creating the migration. If two PRs collide, rebase the older PR and regenerate the timestamp.

  • Branch creates a migration with a unique timestamp.
  • PR runs migrations automatically against a staging database.
  • Reviewer checks up/down and lock/time impact.
  • Merge to main only after staging succeeds.
  • A release tag triggers production migrations.
# Makefile (trecho) para padronizar comandos
migrate:
	bash db/scripts/migrate.sh $(DATABASE_URL)

rollback:
	bash db/scripts/rollback.sh $(DATABASE_URL) $(VERSION)

# Uso:
# make migrate DATABASE_URL=postgres://user:pass@host:5432/dbname
# make rollback DATABASE_URL=... VERSION=20241003-120000

Rollbacks in practice: strategies by change type

Not every change allows a trivial rollback. Plan reversal in the PR. For potentially costly DDL, perform the rollout in two phases: first make the change compatible, then consolidate when it’s safe.

For data, avoid large in‑transaction updates inside the migration. Prefer idempotent, observable jobs that run outside the migration transaction and record progress.

If reversal fails or the impact is wide, consider recovery strategies like point‑in‑time recovery. See when that’s useful in our article on how to recover a dropped table with PITR.

  • Add column: allow NULL or cheap DEFAULT; backfill outside the migration.
  • Index: use CREATE INDEX CONCURRENTLY; rollback with DROP INDEX CONCURRENTLY.
  • Rename/change type: create a new column, sync, then cutover.
  • Remove column: disable app usage first, monitor, then drop.
  • Data fix: record affected rows to allow reversal if needed.
-- Exemplo de data fix com trilha para reversão
BEGIN;
CREATE TABLE IF NOT EXISTS ops_datafix_log (
  key text PRIMARY KEY,
  details jsonb,
  applied_at timestamptz DEFAULT now()
);

-- Marca registros afetados antes de atualizar
INSERT INTO ops_datafix_log(key, details)
SELECT '20241005_fix_status_orders', jsonb_build_object('count', count(*))
FROM orders WHERE status = 'pending';

-- Atualiza de forma idempotente
UPDATE orders SET status = 'new'
WHERE status = 'pending';
COMMIT;

-- Para reverter, leia ops_datafix_log e volte ao estado anterior se fizer sentido.

Lightweight tools that work well for small teams

Small teams can start with plain SQL + scripts. This reduces dependencies and forces clarity. When the volume grows, migration tools help with tracking, validations and explicit dependencies.

Selection criteria: PR review friendliness, up/down support, transactional execution, locking behavior, dry‑run, and simple CI/CD integration. Avoid tools that hide SQL and make auditing harder.

  • SQL + scripts: full control, little magic.
  • Dedicated tools: execution trail and declarative dependencies.
  • Avoid auto-generated migrations from diffs without human review.
  • Standardize environment variables and execution logs.

CI/CD integration and a safe release checklist

Automate running migrations in staging for every PR. In production, trigger migrations alongside the release, with safety parameters and centralized logs. Monitor locks, execution time and rows modified.

Include database health checks and timeout policies. Large data jobs should run outside peak hours and incrementally.

If deploy failures are frequent, map bottlenecks and create a remediation plan. This process integrates with a deployment audit and remediation plan to gain predictability.

  • Preflight: tested backup and enough free disk space.
  • SET LOCAL statement_timeout/lock_timeout in migrations.
  • Logical lock (pg_advisory_xact_lock) to avoid concurrency.
  • Report: applied versions, duration, errors, table sizes.
  • Data jobs: small, reentrant batches.
#!/usr/bin/env bash
# db/scripts/migrate.sh
set -euo pipefail
DATABASE_URL="$1"
export PGCONNECT_TIMEOUT=5

psql "$DATABASE_URL" -v ON_ERROR_STOP=1 <<'SQL'
CREATE TABLE IF NOT EXISTS schema_migrations (
  version text PRIMARY KEY,
  applied_at timestamptz DEFAULT now()
);
SQL

for f in $(ls -1 db/migrations/*_up.sql | sort); do
  ver=$(basename "$f" | cut -d'_' -f1)
  if ! psql -Atqc "select 1 from schema_migrations where version='${ver}'" "$DATABASE_URL" | grep -q 1; then
    echo "Applying $f";
    psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f "$f"
    psql "$DATABASE_URL" -c "insert into schema_migrations(version) values ('${ver}') on conflict do nothing;"
  fi
done

When to use a migration, an ad‑hoc script or a data job

Not every change fits inside a transactional migration. Use this quick table to decide the right approach. For complex, zero‑downtime migrations evaluate patterns like CDC. See the guide on zero‑downtime database migration with CDC.

Scenario Main approach Recommended rollback Notes
Add column with a cheap default Transactional migration (ALTER TABLE ... ADD COLUMN DEFAULT) DROP COLUMN if safe Backfill outside the migration if the table is large.
Create index on a large table CREATE INDEX CONCURRENTLY DROP INDEX CONCURRENTLY Avoids long write locks.
Change type of a populated column New column + incremental backfill + cutover Point the app back to the old column Plan windows and monitor impact.
Rename table/column used by the app Compatibility in stages (views/duplicated columns) Restore old names Flip a feature flag in the app before the final drop.
Massive data correction Idempotent batch job with progress log Revert using log / previous state Do not mix with critical deploy steps.

Frequently asked questions

Which tool should small teams use for migrations?

Start with plain SQL and versioned scripts. This forces clarity, eases PR review and reduces dependencies. If volume grows, adopt a migration tool that provides execution history, up/down scripts and simple CI/CD integration. The key is consistent standards and auditability.

How do I ensure safe rollbacks in PostgreSQL?

Plan the reversal in the PR, keep down.sql simple and testable, and avoid irreversible data fixes. For risky changes roll out in phases and monitor. If impact is wide, use point‑in‑time recovery or reversible cutover patterns according to your RTO/RPO.

How can I avoid migration conflicts between branches?

Use a timestamp in the filename and generate the migration when opening the branch. If two PRs collide, rebase and regenerate the timestamp. Automate running migrations in staging per PR so the final merge order reflects the production application sequence.

Can I run large data updates inside a migration?

Prefer not to. Large updates hold locks and increase failure windows. Run data fixes as idempotent batched jobs with progress logging and the ability to pause. Migrations should create structures and triggers; data processing should run separately.

Which timeouts should I set in migrations?

Set SET LOCAL lock_timeout to avoid waiting indefinitely for locks and SET LOCAL statement_timeout to prevent excessively long statements. Exact values depend on your traffic and table sizes. The goal is to fail fast and predictably without blocking production.

How to handle deadlocks when applying migrations?

Reduce transaction scope, order operations consistently and prefer compatible ALTERs. Avoid large data fixes inside migrations. If deadlocks persist, audit concurrent jobs and access order to tables. See the guide on auditing cron jobs for PostgreSQL locks & deadlocks.

Want to strengthen your migrations and rollback process?

Let's talk about your context, current risks and practical next steps to structure a predictable change flow for PostgreSQL.

Discuss your database

See also