Checklist de formatação SQL antes de implantar uma migration

Um checklist prático para passar antes que uma migration de esquema ou de dados vá para revisão e para produção. Cada item é curto, explica por que importa, e a maioria pode ser verificada em um minuto. Um exemplo completo no final mostra uma migration antes e depois da formatação.

O checklist num relance

  1. O arquivo está formatado com as opções documentadas do time.
  2. O arquivo contém uma única mudança lógica.
  3. Todo INSERT lista as suas colunas.
  4. Constraints e índices têm nomes explícitos.
  5. Os comentários explicam o porquê, não o quê.
  6. Todo UPDATE e DELETE tem uma cláusula WHERE revisada.
  7. Os limites das transações são intencionais.
  8. A migration pode ser executada de novo com segurança, ou tem um rollback.
  9. Quem revisa leu o diff formatado, e não apenas a descrição.

Migrations merecem mais cuidado do que consultas comuns porque rodam uma única vez, contra dados de produção, muitas vezes sob pressão de tempo, e muitos dos seus efeitos não podem ser desfeitos com um simples revert. Legibilidade faz parte da segurança: quem revisa só consegue pegar o que consegue ler.

1. Formatação consistente com opções documentadas

Formate toda migration com as mesmas configurações e registre essas configurações num lugar onde o time vá encontrá-las, por exemplo no guia de contribuição do repositório. No SQL Format, isso significa cinco valores: Espaços de indentação, Quantidade máxima de colunas, Vírgulas, Palavras-chave e Parênteses em nova linha. Quando todos usam os mesmos valores, um diff mostra apenas o que mudou na lógica.

Formate antes de fazer o commit do arquivo, e não depois que a revisão começar. Reformatar um arquivo que já está em revisão enterra os comentários de verdade sob ruído de layout. O guia sobre formatação, revisão de código e controle de versão trata desse fluxo de trabalho com mais detalhes.

2. Uma mudança lógica por migration

Uma migration deve fazer uma coisa que possa ser descrita numa frase: "adicionar last_reminded_at em invoices e preencher os dados existentes". Uma coluna, o índice que a apoia e o backfill dos dados andam juntos. Uma constraint sem relação em outra tabela, não. Arquivos pequenos são mais fáceis de revisar e de reverter, e, quando algo falha no meio do caminho, a mensagem de erro aponta para uma mudança fácil de entender.

3. Lista de colunas explícita no INSERT

Um INSERT sem lista de colunas depende da ordem atual das colunas da tabela. Quando uma migration posterior adiciona uma coluna, o comando falha; quando colunas do mesmo tipo são reordenadas, ele pode funcionar e colocar valores no lugar errado.

-- Breaks, or silently swaps values, when a column is added or reordered
INSERT INTO job_settings VALUES ('dunning', 'min_days_between_reminders', '7');

-- Survives schema changes and documents itself
INSERT INTO job_settings (job_name, setting_key, setting_value)
VALUES ('dunning', 'min_days_between_reminders', '7');

Depois da formatação, cada coluna e cada valor ficam na sua própria linha, de modo que quem revisa pode conferi-los aos pares.

4. Nomes explícitos para constraints e índices

Se você não dá nome a uma constraint, o banco de dados gera um, e o nome gerado depende do banco de dados e, às vezes, do ambiente. Migrations posteriores que precisem removê-la ou alterá-la terão então que descobrir esse nome, e mensagens de erro como "violates check constraint" passam a ajudar menos.

-- Unnamed: the database invents a name such as invoices_total_check
-- or SYS_C0012345, and it differs between environments
ALTER TABLE invoices ADD CHECK (total >= 0);

-- Named: predictable in error messages, monitoring and later migrations
ALTER TABLE invoices
    ADD CONSTRAINT ck_invoices_total_non_negative CHECK (total >= 0);

ALTER TABLE invoice_reminders
    ADD CONSTRAINT fk_invoice_reminders_invoice
    FOREIGN KEY (invoice_id) REFERENCES invoices (id);

Escolha um padrão e siga-o, por exemplo os prefixos pk_, fk_, uq_, ck_ e ix_ seguidos da tabela e das colunas ou da regra. Verifique o tamanho máximo de identificadores do seu banco de dados, que em alguns sistemas é menor do que você imagina.

5. Comentários que explicam o porquê

O SQL já diz o que a migration faz. Um comentário no topo deve dizer por quê: o problema que ela resolve, o ticket e como desfazê-la. Dentro do arquivo, comente tudo o que quem revisa teria que perguntar, como o motivo de um backfill ignorar algumas linhas ou de um valor padrão ter sido escolhido. O SQL Format mantém os comentários -- e /* */ no lugar, então eles sobrevivem à formatação.

6. Uma cláusula WHERE revisada em todo UPDATE e DELETE

Uma correção de dados sem cláusula WHERE altera todas as linhas, e uma cláusula WHERE com um OR mal posicionado pode fazer quase o mesmo. Antes do deploy, rode um SELECT COUNT(*) com exatamente a mesma condição e compare o número com o que o ticket espera:

-- 1. Count what the WHERE clause matches, and compare with the ticket
SELECT COUNT(*) FROM sessions WHERE expires_at < DATE '2026-01-01';

-- 2. Run the change with the same WHERE clause, copied, not retyped
DELETE FROM sessions WHERE expires_at < DATE '2026-01-01';

A formatação ajuda aqui: com uma condição por linha e os grupos entre parênteses indentados, uma lógica que mistura AND/OR fica muito mais fácil de verificar. A mesma armadilha é descrita em erros comuns de legibilidade em SQL. Para tabelas grandes, considere também excluir ou atualizar em lotes, para manter pequenos os locks e os logs de transação.

7. Limites de transação intencionais

Se uma migration que falhou deixa o banco de dados alterado pela metade ou não depende do banco de dados:

  • PostgreSQL executa a maior parte do DDL dentro de transações, então uma migration envolvida numa transação é aplicada por completo ou não é aplicada. Alguns comandos, como CREATE INDEX CONCURRENTLY, não podem rodar dentro de um bloco de transação e precisam de uma migration própria.
  • MySQL e MariaDB fazem commit implícito antes e depois da maioria dos comandos DDL. Uma migration que falha no terceiro comando deixa os dois primeiros aplicados, então planeje a ordem e os passos de recuperação.
  • Oracle também faz commit implícito em torno do DDL, com a mesma consequência.

Muitas ferramentas de migration já envolvem cada arquivo numa transação nos bancos de dados que suportam DDL transacional. Verifique o que a sua faz antes de adicionar BEGIN e COMMIT à mão, e nunca misture um backfill longo de dados com DDL que obtém locks fortes na mesma transação sem considerar por quanto tempo esses locks ficarão retidos.

8. Idempotente ou reversível

Deploys são repetidos. Uma migration deve ser segura para rodar duas vezes ou vir acompanhada de um caminho de volta testado. Proteções como IF NOT EXISTS (onde o seu banco de dados as suportar) e cláusulas WHERE que excluem as linhas já corrigidas tornam uma segunda execução inofensiva:

-- PostgreSQL: safe to re-run
ALTER TABLE invoices ADD COLUMN IF NOT EXISTS last_reminded_at timestamptz;
CREATE INDEX IF NOT EXISTS ix_invoices_last_reminded_at ON invoices (last_reminded_at);

-- Data fix: the WHERE clause makes a second run a no-op
UPDATE invoices
SET status = 'void'
WHERE status = 'draft'
  AND created_at < DATE '2025-01-01';

Para mudanças que não podem ser executadas de novo, escreva o script de rollback junto com a migration e faça referência a ele no comentário de cabeçalho. Remover uma coluna ou uma tabela só é reversível se os dados tiverem sido copiados para algum lugar antes.

9. Revise o diff formatado

Por fim, leia o diff que quem revisa vai ver. Como o arquivo foi formatado com as opções combinadas, cada linha alterada deve corresponder a uma mudança real. Procure os itens acima: constraints sem nome, listas de colunas faltando, condições sem parênteses e comentários que não batem mais com o código.

Exemplo completo

Um cabeçalho de migration que responde às primeiras perguntas de quem revisa antes mesmo que elas sejam feitas:

-- V42__invoices_last_reminded_at.sql
-- Why: the dunning job resends reminders because it cannot tell when
-- the last one went out. Ticket: BILL-318.
-- Rollback: V42_1__drop_last_reminded_at.sql (drops the index and column).
-- Run by hand with psql; a migration tool may add its own transaction.

BEGIN;

-- formatted statements go here, in the order they must run

COMMIT;

E os comandos, como foram digitados inicialmente e depois de formatados com as opções padrão. Os comentários ficam onde estavam, o ALTER TABLE fica numa linha só, o CREATE INDEX coloca a sua cláusula ON na linha seguinte, a tabela derivada do backfill é indentada dentro dos seus parênteses e INSERT INTO job_settings ( fica numa linha só, com as colunas e os valores listados um por linha logo abaixo. Palavras de DDL como INDEX e nomes de tipos como TIMESTAMPTZ seguem a configuração de caixa das palavras-chave como qualquer outra palavra-chave.

Antes
-- V42: track when an invoice was last reminded, so the dunning job stops resending the same reminder
alter table invoices add column last_reminded_at timestamptz;
create index ix_invoices_last_reminded_at on invoices (last_reminded_at);
-- backfill from the reminder log; invoices never reminded stay null
update invoices set last_reminded_at = r.sent_at from (select invoice_id, max(sent_at) as sent_at from invoice_reminders group by invoice_id) r where r.invoice_id = invoices.id and invoices.status = 'open';
insert into job_settings (job_name, setting_key, setting_value) values ('dunning', 'min_days_between_reminders', '7');
Depois, formatado pelo SQL Format
Opções: Espaços de indentação 4 · Quantidade máxima de colunas 120 · Vírgulas: Depois · Palavras-chave: Maiúsculas · Parênteses em nova linha: não
-- V42: track when an invoice was last reminded, so the dunning job stops resending the same reminder
ALTER TABLE invoices ADD COLUMN last_reminded_at TIMESTAMPTZ;
CREATE INDEX ix_invoices_last_reminded_at
ON invoices(last_reminded_at);
-- backfill from the reminder log; invoices never reminded stay null
UPDATE invoices
SET last_reminded_at = r.sent_at
FROM (
    SELECT invoice_id,
        MAX(sent_at) AS sent_at
    FROM invoice_reminders
    GROUP BY invoice_id
) r
WHERE r.invoice_id = invoices.id
AND invoices.status = 'open';
INSERT INTO job_settings (
    job_name,
    setting_key,
    setting_value
)
VALUES (
    'dunning',
    'min_days_between_reminders',
    '7'
);

Repare como a cláusula WHERE do backfill agora mostra as suas duas condições em linhas separadas, e como o INSERT deixa evidente qual valor vai em qual coluna. São exatamente essas as linhas em que quem revisa deve gastar tempo.

Perguntas frequentes

Devo reformatar migrations antigas que já foram aplicadas?

Normalmente, não. Muitas ferramentas de migration armazenam um checksum de cada arquivo aplicado e se recusam a rodar, ou emitem um aviso, se ele mudar. Aplique as regras de formatação apenas às novas migrations.

O DDL é transacional em todos os bancos de dados?

Não. O PostgreSQL consegue desfazer a maior parte do DDL dentro de uma transação, enquanto MySQL, MariaDB e Oracle fazem commit implícito em torno dos comandos DDL. Verifique o seu banco de dados antes de contar com um rollback.

Preciso dar nome aos índices também, além das constraints?

Sim. Os nomes dos índices aparecem em planos de execução, no monitoramento e em migrations posteriores que os removem ou reconstroem, então um nome previsível economiza tempo para todos que os leem.