---
id: zero-downtime-migrations
domain: data
agents: [data-engineer]
when: "ao evoluir o schema em produção sem downtime nem quebrar a aplicação"
---

# Zero-downtime migrations — evoluir o schema sem parar a aplicação

A maioria das migrations quebra produção por um motivo só: **trata schema e código como se mudassem
no mesmo instante.** Eles não mudam. Entre o deploy do schema e o deploy de todas as réplicas da
aplicação existe uma janela em que **a versão antiga do app e a versão nova do schema convivem** — e é
nessa janela que tudo explode.

Este pack é o playbook **expand/contract** (também chamado de parallel change), o mesmo que ferramentas
como [pgroll](https://github.com/xataio/pgroll) automatizam para Postgres. A régua é grounded em SQL
real e nas garantias reais do Postgres — não em "boas práticas" vagas.

## O problema / o tell

Migration de produção é medíocre quando tem qualquer destes:

1. **`ALTER TABLE ... ADD COLUMN ... NOT NULL` sem default** — varre e reescreve a tabela inteira sob
   `ACCESS EXCLUSIVE LOCK`. Trava toda leitura E escrita por segundos a minutos. Em tabela grande, é
   downtime.
2. **`RENAME COLUMN` ou `DROP COLUMN` num único deploy** — a aplicação antiga ainda referencia o nome
   velho. No instante do rename, toda query da versão antiga falha com `column does not exist`.
3. **Trocar tipo de coluna com `ALTER COLUMN ... TYPE`** — força reescrita de tabela + invalida o plano
   das queries em flight. `int` → `bigint`, `text` → `varchar(n)`, todos reescrevem.
4. **`CREATE INDEX` (sem `CONCURRENTLY`)** — pega `SHARE LOCK`, bloqueia todas as escritas na tabela
   durante a construção inteira do índice.
5. **`ADD CONSTRAINT ... CHECK` / `FOREIGN KEY` direto** — valida a tabela inteira sob lock antes de
   liberar.
6. **Migration acoplada ao deploy do app** — "rodo o ALTER e subo o código junto". Não existe "junto"
   num sistema com N pods atrás de um load balancer; existe uma janela onde versões coexistem.
7. **Sem caminho de rollback** — a migration aplica, dá problema em produção, e a única saída é outra
   migration corretiva no susto (que também pode quebrar).
8. **Backfill num `UPDATE` único** — `UPDATE tabela SET nova = f(velha)` numa tabela de milhões de
   linhas trava a tabela inteira numa transação gigante, infla o WAL e segura `VACUUM`.

O tell-mãe: **a migration assume que schema e app mudam atomicamente.** Eles nunca mudam. Toda mudança
quebradora precisa virar uma sequência de mudanças **aditivas e reversíveis** que passam por um estado
onde o schema é compatível com a versão antiga E a nova do app ao mesmo tempo.

## O conhecimento / os princípios

### 1. Expand → Contract: nunca mude, sempre adicione e depois remova

Toda mudança quebradora é decomposta em duas fases separadas por **deploys distintos**:

- **Expand (`pgroll start`):** adiciona o novo elemento *ao lado* do antigo, sem quebrar nada. Mudanças
  puramente aditivas. O schema fica compatível com a versão velha e a nova do app simultaneamente.
- **Contract (`pgroll complete`):** só depois que **nenhuma** instância da aplicação antiga está mais
  no ar, remove o elemento velho e as estruturas temporárias.

Entre as duas fases há um período de coexistência. É ele que dá o zero-downtime. Em pgroll o ciclo é
literal:

```bash
pgroll start   migrations/02_make_description_not_null.yaml --postgres-url "postgres://..."
# ... deploy do app novo, validação, espera todo app velho sair ...
pgroll complete --postgres-url "postgres://..."
# ou, antes de completar, se algo deu errado:
pgroll rollback --postgres-url "postgres://..."
```

A regra inegociável: **expand e contract nunca no mesmo deploy.** Entre eles entra o deploy do código
que passa a usar o novo elemento.

### 2. Adicionar coluna `NOT NULL` em fases (o caso canônico)

Você **não** faz `ADD COLUMN ... NOT NULL`. Você faz aditivo, backfilla, e só então valida a constraint
— cada passo sem lock longo.

| Fase | Operação | Lock | Por quê é seguro |
|---|---|---|---|
| 1. Add nullable | `ADD COLUMN status text` (sem NOT NULL) | metadata-only no PG 11+ | Não reescreve a tabela; default volátil é evitado |
| 2. Backfill | `UPDATE` em **lotes** (10k linhas/batch) | row-level por lote | Não trava a tabela inteira numa transação só |
| 3. Constraint `NOT VALID` | `ADD CONSTRAINT ck CHECK (status IS NOT NULL) NOT VALID` | breve | `NOT VALID` **não** varre as linhas existentes |
| 4. Validar | `VALIDATE CONSTRAINT ck` | `SHARE UPDATE EXCLUSIVE` | Não bloqueia leitura nem escrita normal |
| 5. (opcional) Promover | `SET NOT NULL` aproveitando a CHECK já validada (PG 12+) | breve | PG reusa a CHECK e pula o full scan |

É exatamente o que pgroll gera por baixo: cria a coluna física nova, marca a constraint como
`CHECK (...) NOT NULL NOT VALID`, backfilla, e valida. O `up`/`down` da migration declara **como
preencher** os valores existentes:

```yaml
operations:
  - alter_column:
      table: users
      column: description
      nullable: false
      up:   SELECT CASE WHEN description IS NULL THEN 'No description provided' ELSE description END
      down: description
```

- `up` = como popular a coluna nova a partir da antiga (backfill + dual-write para frente).
- `down` = como popular a antiga a partir da nova (dual-write para trás, usado no rollback).

Coluna nullable **com default** é o caso fácil — não precisa backfill, pgroll trata como aditivo puro:

```yaml
operations:
  - add_column:
      table: reviews
      column:
        name: rating
        type: text
        default: '0'
```

### 3. Dual-write por trigger: manter coluna velha e nova em sincronia

Durante a coexistência, escritas chegam pelas duas versões do app. A versão antiga escreve na coluna
velha; a nova, na coluna nova. Sem sincronização, uma fica desatualizada. pgroll resolve criando uma
**coluna física temporária** (ex.: `_pgroll_new_description`) e um **trigger** que propaga cada escrita
para a contrapartida, aplicando a expressão `up`/`down`. O esqueleto do que a trigger faz:

```sql
-- conceito do que pgroll gera automaticamente durante a migration ativa
CREATE OR REPLACE FUNCTION _pgroll_sync_description() RETURNS trigger AS $$
BEGIN
  -- app novo escreveu na coluna nova → propaga pra velha (expressão down)
  IF NEW._pgroll_new_description IS DISTINCT FROM OLD._pgroll_new_description THEN
    NEW.description := NEW._pgroll_new_description;
  -- app velho escreveu na coluna velha → propaga pra nova (expressão up)
  ELSE
    NEW._pgroll_new_description :=
      CASE WHEN NEW.description IS NULL THEN 'No description provided' ELSE NEW.description END;
  END IF;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER _pgroll_trigger_users__pgroll_new_description
  BEFORE INSERT OR UPDATE ON users
  FOR EACH ROW EXECUTE FUNCTION _pgroll_sync_description();
```

Se você faz **sem** pgroll, este é o padrão: trigger `BEFORE INSERT OR UPDATE` que mantém as duas
colunas coerentes durante toda a janela. No `complete`, a trigger e a coluna velha são derrubadas.

### 4. Views versionadas: cada versão do app enxerga seu próprio schema

O truque que torna rename/drop seguros: as aplicações **não acessam as tabelas físicas diretamente**.
Cada migration cria um **schema versionado** com **views** sobre as tabelas reais. pgroll nomeia os
schemas por versão:

```
public_01_initial_schema      -- view: description
public_02_rename_description   -- view: bio  (mesma coluna física, nome novo)
```

A view da versão antiga continua expondo `description`; a da versão nova expõe `bio`. **A mesma coluna
física, dois nomes lógicos.** Por isso o rename não quebra ninguém: cada app vê o nome que conhece.

O app escolhe a versão setando o `search_path` — **dentro de uma transação**, senão a sessão pode pegar
outra conexão do pool:

```bash
export PGROLL_SCHEMA_VERSION=$(pgroll latest --with-schema --postgres-url "postgres://...")
```

```typescript
// driver postgres.js / node-postgres — SET e query NO MESMO transaction/session
const schema = process.env.PGROLL_SCHEMA_VERSION || 'public';
await db.transaction(async (tx) => {
  await tx.execute(`SET search_path TO ${schema}`);
  return tx.select().from(users);
});
```

Driver HTTP stateless **não serve** para esse padrão: `SET search_path` e a query precisam compartilhar
a mesma sessão.

### 5. Rename e Drop em fases seguras

**Rename** com views versionadas é trivial: a coluna física nunca muda de nome; só a view da nova versão
expõe o nome novo. Sem views, o rename vira um expand/contract de coluna inteira (add nova → dual-write
→ migrar leitores → drop velha) — mais caro, e é o motivo de as views existirem.

**Drop** segue a ordem inversa do add, e **drop é sempre na fase contract**:

1. Deploy do app que **para de ler/escrever** a coluna.
2. Confirmar que nenhuma instância antiga está no ar (a que ainda usa a coluna).
3. Só então `ALTER TABLE ... DROP COLUMN` (rápido, metadata-only — o espaço é recuperado pelo `VACUUM`).

Nunca dropar antes de o último app que a usa ter saído. É o erro nº 2 do tell.

### 6. `CREATE INDEX CONCURRENTLY` — e por que NÃO dentro de transação

Índice em produção é **sempre** `CONCURRENTLY`:

```sql
CREATE INDEX CONCURRENTLY idx_orders_status ON orders (status);
```

- `CREATE INDEX` normal pega `SHARE LOCK` → **bloqueia toda escrita** na tabela durante a construção
  inteira.
- `CREATE INDEX CONCURRENTLY` constrói **sem bloquear** insert/update/delete concorrentes.

Mas vem com regras duras, todas reais do Postgres:

| Restrição | Consequência prática |
|---|---|
| **Não pode rodar dentro de transaction block** | Faz dois scans e espera transações abertas terminarem; precisa commitar entre as fases internas. Por isso pgroll executa `create_index` **fora** da transação da migration. |
| Faz **dois scans** da tabela + espera transações concorrentes | Demora bem mais que o `CREATE INDEX` normal — planeje para tabelas grandes. |
| Pode **falhar no meio** | Deixa um **índice inválido** (`INVALID`) para trás. Diferente do `CREATE INDEX` normal, que faz rollback limpo. |
| Recovery do índice inválido | `DROP INDEX CONCURRENTLY idx_orders_status;` e recriar. Detectar com a query de índices inválidos abaixo. |

```sql
-- encontrar índices que falharam no meio do CONCURRENTLY
SELECT c.relname
FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
WHERE i.indisvalid = false;
```

Em pgroll é uma operação declarativa, e ele cuida de rodá-la fora da transação:

```json
{ "create_index": { "name": "idx_orders_status", "table": "orders", "columns": ["status"] } }
```

### 7. Compatibilidade backward/forward entre app e schema

A regra que governa a janela de coexistência:

- **Backward-compatible (expand):** o schema novo **não quebra** o app antigo. Add coluna nullable, add
  índice, add view — o app velho ignora. Sempre seguro de deployar primeiro.
- **Forward-compatible (app):** o app novo **funciona com o schema velho** durante a janela (ex.: lê a
  coluna nova mas tolera ela vazia/sincronizada por trigger).

Ordem de deploy que respeita as duas:

```
1. EXPAND  schema (aditivo, backward-compat)   → pgroll start
2. DEPLOY  app novo (forward-compat, dual-write ativo)
3. ESPERA  todo app antigo sair de produção
4. CONTRACT schema (drop do velho)             → pgroll complete
```

Inverter qualquer passo abre uma janela de erro. Deployar o app novo antes do expand → ele lê coluna que
não existe. Fazer contract antes de o app velho sair → ele lê coluna que sumiu.

### 8. Rollback seguro de cada fase

O ponto do expand/contract: **enquanto não houve `complete`, o rollback é instantâneo e seguro.** A
coluna velha ainda existe, a trigger ainda sincroniza, a view antiga ainda responde.

| Estado | Rollback |
|---|---|
| Após `start`, antes de `complete` | `pgroll rollback` — derruba coluna nova, triggers e views da versão nova. App antigo nunca foi tocado. Zero risco. |
| Após `complete` | A coluna velha já foi dropada. Rollback agora = nova migration expand/contract no sentido inverso (não é "desfazer"). |
| Backfill em andamento | Interromper é seguro: as linhas já backfilladas ficam corretas (dual-write mantém), e o `down` reverte. |

A `down` de cada operação **é** o plano de rollback declarado de antemão — não algo improvisado no susto.
Para DDL que pgroll não cobre, use o escape hatch `sql` com `up`/`down`, ciente de que **não vem com a
garantia de zero-downtime**:

```yaml
operations:
  - sql:
      up:   CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)
      down: DROP TABLE users
```

## Checklist "isto vai derrubar produção?"

Antes de aplicar, qualquer "sim" é um risco a corrigir:

- [ ] Tem `ADD COLUMN ... NOT NULL` sem default (full table rewrite sob `ACCESS EXCLUSIVE`)?
- [ ] Tem `RENAME`/`DROP COLUMN` no mesmo deploy do código que ainda usa o nome velho?
- [ ] Tem `ALTER COLUMN ... TYPE` que reescreve a tabela?
- [ ] Tem `CREATE INDEX` sem `CONCURRENTLY`?
- [ ] Tem `CREATE INDEX CONCURRENTLY` dentro de um transaction block (vai falhar)?
- [ ] Adicionou índice concorrente sem checar `indisvalid = false` depois (índice fantasma inválido)?
- [ ] Tem `ADD CONSTRAINT CHECK/FK` sem `NOT VALID` + `VALIDATE` em passo separado?
- [ ] O backfill é um `UPDATE` único na tabela inteira em vez de lotes (~10k linhas/batch)?
- [ ] A migration e o deploy do app estão acoplados (sem janela de coexistência)?
- [ ] Existe coluna velha sendo dropada **antes** de confirmar que o app antigo saiu de produção?
- [ ] Falta `down`/rollback declarado para cada operação?
- [ ] App usa `SET search_path` para schema versionado **fora** de uma transação (pode pegar outra conexão do pool)?

## Tabela de decisão "use X quando Y"

| Quando você precisa... | Use isto | Não use isto |
|---|---|---|
| Adicionar coluna obrigatória | add nullable → backfill em lotes → `CHECK NOT VALID` → `VALIDATE` → `SET NOT NULL` | `ADD COLUMN NOT NULL` direto |
| Adicionar coluna com valor padrão fixo | `ADD COLUMN ... DEFAULT 'x'` (aditivo, sem backfill no PG 11+) | backfill manual |
| Renomear coluna | view versionada expondo o nome novo (mesma coluna física) | `RENAME COLUMN` num deploy só |
| Remover coluna | drop **na fase contract**, após o último app que a usa sair | drop junto com o deploy do app |
| Trocar tipo de coluna | nova coluna + dual-write (`up`/`down`) + migrar leitores + drop velha | `ALTER COLUMN ... TYPE` in-place |
| Criar índice em produção | `CREATE INDEX CONCURRENTLY` (fora de transação) | `CREATE INDEX` (trava escritas) |
| Adicionar CHECK/FK | `ADD CONSTRAINT ... NOT VALID` depois `VALIDATE CONSTRAINT` | `ADD CONSTRAINT` que valida sob lock |
| Backfill de tabela grande | `UPDATE` em lotes (~10k/batch), commit por lote | um `UPDATE` na transação inteira |
| Manter coluna velha/nova coerentes na janela | trigger de dual-write (`BEFORE INSERT/UPDATE`) com `up`/`down` | confiar que o app escreve nas duas |
| Servir versões diferentes do app | schemas versionados + views + `search_path` por sessão | um schema só pra todos |
| Reverter antes de finalizar | `pgroll rollback` (coluna velha ainda viva) | nova migration corretiva no susto |
| Reverter após `complete` | nova migration expand/contract no sentido inverso | tentar "desfazer" o complete |
| DDL não coberta (zero-downtime) | escape hatch `sql` com `up`/`down`, ciente da perda de garantia | rodar DDL crua sem plano de `down` |

---

**Fonte:** [xataio/pgroll](https://github.com/xataio/pgroll) ·
[Expand/contract com pgroll](https://xata.io/blog/pgroll-expand-contract) ·
[Como o pgroll funciona por dentro](https://xata.io/blog/pgroll-internals) ·
[Guia pgroll (Neon)](https://neon.com/guides/pgroll) ·
[Postgres: CREATE INDEX](https://www.postgresql.org/docs/current/sql-createindex.html)
