Migrations em Go são a diferença entre “rodei um SQL no banco de produção” e um processo repetível, revisável e seguro para evoluir o schema da aplicação. Toda API cresce: uma coluna nova aparece, um índice vira obrigatório, uma tabela precisa ser dividida, um campo deixa de aceitar NULL, um enum muda, uma constraint passa a proteger uma regra de negócio. Sem migrations, cada mudança vira memória tribal e risco operacional.
Go não impõe um framework único para banco de dados. Você pode usar database/sql, pgx, sqlc, GORM, Ent ou uma camada própria. Essa liberdade é boa, mas também significa que o time precisa escolher uma estratégia explícita para versionar schema. O objetivo não é “ter uma ferramenta bonita”; é garantir que todo ambiente — laptop, CI, staging e produção — saiba exatamente qual versão do banco está rodando.
Este guia mostra como organizar migrations em projetos Go, quando usar golang-migrate ou goose, como escrever alterações reversíveis, como evitar deploy quebrado e quais cuidados importam em bancos maiores. Se você ainda está montando a base de persistência, leia também Go com PostgreSQL, o tutorial de CRUD com PostgreSQL e o guia de API REST em Go.
O que é uma migration?
Uma migration é um arquivo versionado que descreve uma mudança no banco de dados. Em vez de alguém executar manualmente ALTER TABLE em produção, a mudança entra no Git, passa por review e é aplicada por uma ferramenta que registra o histórico.
Um fluxo típico cria dois arquivos:
migrations/
000001_create_users.up.sql
000001_create_users.down.sql
000002_add_users_last_login.up.sql
000002_add_users_last_login.down.sql
O arquivo up aplica a mudança. O arquivo down desfaz a mudança quando rollback é possível. A ferramenta mantém uma tabela interna, geralmente chamada schema_migrations, para saber quais versões já foram aplicadas.
Isso resolve três problemas práticos:
- Reprodutibilidade: qualquer pessoa recria o banco a partir do zero.
- Auditoria: toda mudança tem diff, autor, data e motivo.
- Deploy: o pipeline aplica apenas o que ainda falta.
Em times pequenos, migrations podem parecer burocracia. Em times que fazem deploy frequente, elas são infraestrutura básica.
golang-migrate ou goose?
As duas ferramentas mais comuns em projetos Go são golang-migrate e goose.
golang-migrate é direto, popular e funciona bem em pipelines. Ele usa arquivos up e down separados, tem suporte amplo a bancos e costuma ser escolhido por times que querem migrations SQL explícitas.
Instalação para PostgreSQL:
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
Criando uma migration:
migrate create -ext sql -dir migrations -seq create_users_table
Aplicando:
migrate -path migrations \
-database "postgres://app:senha-local@localhost:5432/app?sslmode=disable" \
up
goose também é maduro, mas usa um formato que muitos times acham mais legível quando querem SQL e anotações no mesmo arquivo:
go install github.com/pressly/goose/v3/cmd/goose@latest
Exemplo com goose:
-- +goose Up
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- +goose Down
DROP TABLE users;
Ambas funcionam. A decisão mais importante é escolher uma e padronizar. Misturar ferramentas no mesmo repositório cria histórico quebrado e confusão em incidentes.
Estrutura recomendada no projeto Go
Para uma API simples, esta estrutura é suficiente:
myapp/
cmd/
api/
main.go
migrate/
main.go
internal/
database/
db.go
migrations/
000001_create_users.up.sql
000001_create_users.down.sql
go.mod
O diretório migrations/ fica na raiz para ser fácil de usar no CI, no Dockerfile e localmente. Se você usa go:embed, pode embutir os arquivos no binário de uma CLI interna. Isso ajuda em ambientes onde o deploy só entrega um executável, mas não é obrigatório.
Uma CLI cmd/migrate evita depender de comandos instalados manualmente no servidor. Ela também permite reaproveitar a mesma leitura de configuração da aplicação:
package main
import (
"database/sql"
"log"
"os"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/postgres"
_ "github.com/golang-migrate/migrate/v4/source/file"
_ "github.com/lib/pq"
)
func main() {
db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatal(err)
}
defer db.Close()
driver, err := postgres.WithInstance(db, &postgres.Config{})
if err != nil {
log.Fatal(err)
}
m, err := migrate.NewWithDatabaseInstance("file://migrations", "postgres", driver)
if err != nil {
log.Fatal(err)
}
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
log.Fatal(err)
}
}
Para um serviço pequeno, rodar a ferramenta externa no pipeline é suficiente. Para uma plataforma maior, uma CLI própria deixa logs, configuração e autenticação mais consistentes.
Como escrever migrations seguras
Uma migration boa é pequena, previsível e compatível com o código que já está rodando. O erro comum é pensar nela como um script único de banco, quando na verdade ela faz parte de um deploy distribuído.
Evite mudanças grandes demais:
-- ruim: cria coluna, preenche milhões de linhas e obriga NOT NULL no mesmo passo
ALTER TABLE orders ADD COLUMN external_id TEXT NOT NULL;
Prefira etapas:
-- passo 1: mudança compatível
ALTER TABLE orders ADD COLUMN external_id TEXT;
-- passo 2: backfill em job controlado, fora da migration longa
-- passo 3: constraint depois que os dados estiverem prontos
ALTER TABLE orders ALTER COLUMN external_id SET NOT NULL;
Essa abordagem é mais chata no curto prazo, mas evita travar tabela, estourar timeout e derrubar a aplicação durante o deploy.
Também vale separar DDL de backfill pesado. Criar coluna ou índice é mudança de schema. Atualizar 50 milhões de linhas é operação de dados. O backfill deve ter métricas, lotes, retry e possibilidade de pausa. Em Go, isso combina bem com worker pools e logs estruturados com slog.
Deploy compatível com versão antiga e nova
O padrão mais seguro é expandir, migrar e contrair.
Na fase de expansão, você adiciona o que o código novo precisa sem quebrar o código antigo. Exemplo: adicionar uma coluna nullable, uma tabela nova ou um índice novo.
Na fase de migração, o código passa a escrever nos dois lugares ou começa a preencher a estrutura nova. Essa fase pode durar minutos ou dias, dependendo do volume.
Na fase de contração, você remove a coluna antiga, a tabela antiga ou a compatibilidade temporária somente quando tem certeza de que nenhum processo antigo usa aquilo.
Exemplo prático: trocar users.full_name por users.first_name e users.last_name.
Primeiro deploy:
ALTER TABLE users ADD COLUMN first_name TEXT;
ALTER TABLE users ADD COLUMN last_name TEXT;
O código novo passa a escrever full_name, first_name e last_name. Um job preenche os registros antigos. Depois de validar leituras e métricas, outro deploy muda a leitura para os campos novos. Só no final uma migration remove full_name.
Esse padrão reduz a chance de rollback impossível. Se o código novo falhar, o código antigo ainda entende o banco.
Índices sem travar produção
Índice parece inofensivo, mas pode ser uma das migrations mais perigosas em tabela grande. No PostgreSQL, prefira CREATE INDEX CONCURRENTLY para reduzir bloqueios:
CREATE INDEX CONCURRENTLY idx_orders_user_id_created_at
ON orders (user_id, created_at DESC);
Há um detalhe importante: CREATE INDEX CONCURRENTLY não pode rodar dentro de transação. Algumas ferramentas envolvem migrations em transação por padrão. Nesse caso, você precisa marcar a migration como sem transação ou usar a configuração apropriada da ferramenta.
Também não crie índice “porque talvez ajude”. Use EXPLAIN, métricas reais e consultas críticas. Se o problema for endpoint lento, combine a mudança de índice com observabilidade em Go e testes de carga simples.
Rollback: nem toda migration deve ter down destrutivo
Rollback de banco é mais complexo do que rollback de código. Remover uma coluna apaga dados. Desfazer uma transformação pode ser impossível. Por isso, o arquivo down deve ser honesto.
Para migrations iniciais e tabelas novas, DROP TABLE pode fazer sentido em desenvolvimento. Em produção, uma migration destrutiva deve ser tratada com cuidado e, muitas vezes, com janela própria.
Exemplo melhor para remoções:
-- primeira migration: parar de usar, mas preservar dados
ALTER TABLE users RENAME COLUMN legacy_status TO legacy_status_deprecated;
-- depois de dias ou semanas, com backup validado
ALTER TABLE users DROP COLUMN legacy_status_deprecated;
O objetivo é reduzir irreversibilidade. Se o deploy falhar, você quer voltar o código sem descobrir que a migration apagou a única cópia dos dados.
Rodar migration no startup da aplicação?
Rodar migrations automaticamente quando a API sobe é tentador, mas raramente é a melhor opção em produção. Em Kubernetes, por exemplo, cinco réplicas podem iniciar ao mesmo tempo. Ferramentas boas usam lock, mas ainda assim você misturou ciclo de vida da aplicação com mudança de schema.
Para produção, prefira um job separado no pipeline:
1. Build da aplicação
2. Aplicar migrations compatíveis
3. Subir nova versão da API
4. Rodar smoke tests
5. Executar backfills ou jobs pós-deploy, se necessário
Em desenvolvimento local, rodar migrations no make dev ou no docker compose up é aceitável. O problema é fazer a API de produção depender de permissão ampla de DDL toda vez que inicia.
Checklist para times Go
Antes de aprovar uma migration, faça estas perguntas:
- Ela é pequena o bastante para revisar?
- O código antigo continua funcionando depois dela?
- Há risco de lock em tabela grande?
- Índices usam modo concorrente quando necessário?
- Backfill pesado está fora da migration?
- O rollback preserva dados ou pelo menos deixa o risco claro?
- A migration foi testada em banco limpo e em banco com dados existentes?
- O CI aplica migrations antes de rodar testes de integração?
Esse checklist é especialmente útil para pessoas buscando vagas backend. Muitas descrições de vagas Go citam PostgreSQL, MySQL, Docker, Kubernetes, CI/CD e sistemas distribuídos. Saber explicar migrations seguras em entrevista mostra maturidade além da sintaxe da linguagem. Se você está acompanhando oportunidades fora do ecossistema Go, o portal eu.dev.br reúne vagas de tecnologia no Brasil e ajuda a comparar stacks pedidas pelo mercado.
Exemplo de migration pronta
Um exemplo realista para adicionar autenticação por e-mail verificado:
-- 000007_add_email_verification.up.sql
ALTER TABLE users ADD COLUMN email_verified_at TIMESTAMPTZ;
CREATE TABLE email_verification_tokens (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_email_verification_tokens_user_id
ON email_verification_tokens (user_id);
CREATE INDEX idx_email_verification_tokens_expires_at
ON email_verification_tokens (expires_at);
E o rollback:
-- 000007_add_email_verification.down.sql
DROP TABLE email_verification_tokens;
ALTER TABLE users DROP COLUMN email_verified_at;
Essa migration é pequena, fácil de revisar e compatível com o código antigo: adicionar uma coluna nullable não quebra leituras existentes. Depois, o código novo pode começar a usar a tabela de tokens.
Conclusão
Migrations em Go não são um detalhe de ferramenta. Elas são parte do contrato entre código, dados e deploy. Um projeto pode usar golang-migrate, goose, sqlc, pgx, database/sql ou ORM; o ponto central é manter o schema versionado, revisável e seguro para produção.
A regra prática é simples: prefira mudanças pequenas, compatíveis e observáveis. Adicione antes de remover. Separe schema de backfill pesado. Pense no rollback antes de executar. Teste em banco limpo e em banco com dados reais. Com esse cuidado, migrations deixam de ser um momento de medo no deploy e viram uma rotina normal de engenharia backend.