Skip to main content


Migrations​


Quando desenvolvemos uma aplicação que trabalha com banco de dados, normalmente precisamos criar tabelas e definir suas colunas antes de começar a armazenar informações. Entretanto, conforme o projeto evolui, essas estruturas também precisam mudar. Podemos precisar adicionar novas colunas, modificar restrições, criar índices ou remover estruturas que não fazem mais parte da aplicação.


O problema é que essas alterações precisam acontecer sem perder os registros que já estão armazenados e sem deixar diferentes ambientes com estruturas incompatíveis. Para resolver isso, podemos trabalhar com migrations, que permitem organizar e controlar as mudanças realizadas na estrutura do banco de dados ao longo do desenvolvimento.



O que são migrations​


Migrations são arquivos ou conjuntos de instruções responsáveis por representar mudanças na estrutura de um banco de dados. Cada migration descreve uma alteração que precisa ser aplicada, permitindo acompanhar como o banco evoluiu desde sua criação até a estrutura atual.


Imagine que estamos desenvolvendo um sistema de cadastro de produtos. Inicialmente, precisamos armazenar apenas o identificador, o nome e o preço de cada produto. Podemos criar uma tabela com essa estrutura.

-- Cria a estrutura inicial dos produtos.
CREATE TABLE produtos (
id INTEGER PRIMARY KEY,
nome TEXT NOT NULL,
preco_centavos INTEGER NOT NULL
);

Depois de algum tempo, decidimos que também precisamos armazenar uma descrição para cada produto. Em vez de apagar a tabela e criá-la novamente, podemos modificar sua estrutura usando ALTER TABLE.

-- Adiciona uma nova coluna à tabela existente.
ALTER TABLE produtos
ADD COLUMN descricao TEXT;

O comando ALTER TABLE permite modificar determinadas características de uma tabela existente. Nesse exemplo, adicionamos a coluna descricao, mantendo os registros que já estavam armazenados. Como a coluna não possui NOT NULL, os registros anteriores poderão continuar com NULL nesse campo.


Esses dois comandos representam momentos diferentes do desenvolvimento. O primeiro cria a estrutura inicial e o segundo adiciona uma funcionalidade que não existia anteriormente. Se armazenarmos cada mudança em uma migration, conseguimos acompanhar a ordem em que as alterações foram realizadas e identificar quais delas já foram aplicadas em determinado banco.


Uma organização simples poderia ser representada da seguinte maneira.

migrations/
00001_criar_produtos.sql
00002_adicionar_descricao.sql
00003_adicionar_ativo.sql

Cada arquivo representa uma mudança na estrutura do banco. O número inicial identifica a versão da migration, enquanto o restante do nome descreve a alteração que será realizada. Dessa forma, conseguimos identificar a sequência de mudanças sem precisar examinar todo o conteúdo dos arquivos.


Por que não modificar diretamente o CREATE TABLE?​


Durante o desenvolvimento, poderíamos simplesmente alterar o comando CREATE TABLE original sempre que precisássemos adicionar uma coluna. Isso funciona quando o banco ainda não foi criado, mas não resolve o problema das tabelas que já existem.


Imagine que uma aplicação já possui uma tabela produtos com centenas de registros. Se adicionarmos a coluna descricao apenas ao comando CREATE TABLE IF NOT EXISTS, a estrutura existente continuará igual, porque o SQLite não modifica uma tabela apenas por encontrar uma nova definição no comando de criação.

-- Não modifica uma tabela que já existe.
CREATE TABLE IF NOT EXISTS produtos (
id INTEGER PRIMARY KEY,
nome TEXT NOT NULL,
preco_centavos INTEGER NOT NULL,
descricao TEXT
);

Se a tabela já existir sem a coluna descricao, o comando não adicionará esse campo. Para modificar sua estrutura, precisamos executar uma instrução de alteração, como ALTER TABLE, ou outro procedimento compatível com a mudança desejada.


As migrations organizam essas alterações em arquivos independentes, permitindo executar apenas aquelas que ainda não foram aplicadas. Isso também evita que cada pessoa responsável pelo desenvolvimento precise modificar o banco manualmente sempre que uma nova mudança for adicionada ao projeto.


Migrations Up e Down​


Muitas ferramentas de migrations trabalham com dois conceitos, chamados Up e Down. O Up representa as instruções necessárias para aplicar uma alteração, enquanto o Down representa as instruções que tentam desfazer essa alteração.


Imagine uma migration responsável por criar a tabela produtos. Sua operação Up poderia executar um CREATE TABLE, enquanto a operação Down poderia executar um DROP TABLE.

-- Aplica a alteração.
CREATE TABLE produtos (
id INTEGER PRIMARY KEY,
nome TEXT NOT NULL
);

-- Desfaz a alteração.
DROP TABLE produtos;

Nesse exemplo, o primeiro comando cria a tabela, enquanto o segundo remove sua estrutura e todos os registros armazenados nela. Isso significa que desfazer uma migration nem sempre permite recuperar o estado anterior dos dados.


O Down representa uma operação de reversão da estrutura, mas não garante a recuperação de informações removidas. Se uma migration apagar uma coluna que contém dados, por exemplo, executar novamente sua criação não recuperará automaticamente os valores que foram perdidos.


Por isso, migrations que removem tabelas ou colunas precisam de cuidado, principalmente quando o banco já contém informações importantes. Em ambientes de produção, muitas vezes é mais seguro criar uma nova migration corretiva do que desfazer alterações que podem provocar perda de dados.



Criando tabelas com migrations​


Podemos criar e executar migrations manualmente, mas também existem ferramentas que ajudam a controlar quais arquivos já foram aplicados e quais ainda precisam ser executados. No Go, uma dessas ferramentas é o goose, que oferece uma interface de linha de comando e uma biblioteca para trabalhar com migrations.


O goose permite organizar mudanças em arquivos SQL ou funções Go, mantendo um histórico das versões aplicadas ao banco. Ele também oferece comandos para executar novas migrations, consultar o estado atual e desfazer versões que possuem uma operação de reversão definida.


Vamos usar arquivos SQL porque eles permitem manter as alterações da estrutura separadas do código Go, além de facilitar a leitura e o versionamento das mudanças.



Instalando o goose​


O goose é uma ferramenta externa e não faz parte da biblioteca padrão do Go. Podemos instalá-lo usando o comando go install, que compila e instala seu executável.

# Instala a ferramenta goose.
go install github.com/pressly/goose/v3/cmd/goose@latest

O comando instala o executável no diretório de binários configurado pelo Go, normalmente GOPATH/bin ou o caminho definido em GOBIN. Para executar goose diretamente pelo terminal, esse diretório precisa estar disponível na variável de ambiente PATH.


Depois da instalação, podemos verificar se o comando está disponível.

# Mostra a versão instalada.
goose -version

O goose pode trabalhar com diferentes bancos de dados, incluindo SQLite, PostgreSQL e MySQL. Durante nosso estudo, vamos usar SQLite, mantendo a estrutura de arquivos semelhante à que poderia ser usada posteriormente com outros bancos.


Organizando o projeto​


Para começar, podemos criar um diretório chamado migrations, que ficará responsável por armazenar os arquivos SQL com as alterações do banco.

meu-projeto/
main.go
go.mod
go.sum
migrations/

Cada migration será armazenada dentro desse diretório, seguindo uma sequência numérica. Também vamos trabalhar com um arquivo chamado migrations.db, separado dos bancos criados nos exemplos anteriores, para evitar conflitos com tabelas que já possuam outras estruturas.


Podemos criar o diretório pelo terminal.

# Cria o diretório das migrations.
mkdir migrations

Depois disso, usamos o goose para gerar nosso primeiro arquivo de migration.

# Cria uma migration SQL com numeração sequencial.
goose -dir migrations -s create criar_produtos sql

A opção -dir migrations informa o diretório em que os arquivos serão armazenados, enquanto -s solicita uma numeração sequencial. O argumento create indica que queremos criar uma migration, seguido por seu nome e pelo formato sql.


O comando deverá criar um arquivo semelhante a este.

migrations/
00001_criar_produtos.sql

O número 00001 representa a primeira versão, enquanto criar_produtos descreve sua responsabilidade. Se não informarmos a opção -s, o goose normalmente gera nomes baseados em timestamps, que representam a data e o horário de criação do arquivo.


Criando a primeira migration​


Agora precisamos definir quais comandos SQL serão executados quando essa migration for aplicada. O goose reconhece anotações especiais dentro dos arquivos SQL, permitindo separar as instruções de aplicação e reversão.


Vamos adicionar o seguinte conteúdo ao arquivo 00001_criar_produtos.sql.

-- +goose Up

-- Cria a tabela inicial de produtos.
CREATE TABLE produtos (
id INTEGER PRIMARY KEY,
nome TEXT NOT NULL,
preco_centavos INTEGER NOT NULL CHECK (preco_centavos >= 0),
estoque INTEGER NOT NULL DEFAULT 0 CHECK (estoque >= 0)
);

-- +goose Down

-- Remove a tabela criada pela migration.
DROP TABLE produtos;

A anotação -- +goose Up indica o início das instruções que devem ser executadas quando a migration for aplicada. Nesse caso, criamos a tabela produtos, contendo um identificador, um nome, um preço e a quantidade em estoque.


Já -- +goose Down indica as instruções responsáveis por desfazer essa mudança. Como a migration criou uma tabela, sua reversão remove essa mesma tabela usando DROP TABLE. Essa operação também apaga os registros que estiverem armazenados nela.


Essas anotações são reconhecidas pelo goose e precisam seguir sua sintaxe específica, incluindo o prefixo -- +goose. O arquivo deve possuir exatamente uma anotação Up, enquanto a anotação Down é opcional. Quando as duas estiverem presentes, o Up precisa aparecer antes do Down.


Também precisamos finalizar os comandos SQL com ;, porque o goose usa esse caractere para identificar o término das instruções durante o processamento dos arquivos. As anotações não devem possuir espaços antes de --, pois o mecanismo de leitura do goose espera encontrá-las no início da linha.


Executando a primeira migration​


Depois de criar o arquivo SQL, precisamos aplicar a migration ao banco. Para isso, usamos o comando up do goose, informando o diretório das migrations, o driver e o caminho do banco.

# Executa as migrations pendentes.
goose -dir migrations sqlite3 ./migrations.db up

O argumento sqlite3 indica que o goose deve trabalhar com SQLite, enquanto ./migrations.db representa o arquivo do banco que receberá as alterações. O comando up procura as migrations disponíveis e executa aquelas que ainda não foram aplicadas, respeitando a sequência das versões.


Existe uma diferença entre o nome do driver usado pelo goose no terminal e o nome que usamos anteriormente no Go. Nos exemplos com modernc.org/sqlite, abrimos o banco usando sql.Open("sqlite", "migrations.db"). Já o executável do goose reconhece o driver SQLite pelo nome sqlite3. Isso não significa que precisamos alterar o driver da aplicação, porque o goose possui sua própria configuração de acesso ao banco.


Na primeira execução, o goose abre o arquivo SQLite e aplica a migration responsável por criar a tabela produtos. Ele também mantém uma tabela de controle para registrar quais versões foram executadas.


Depois disso, nosso projeto terá uma estrutura semelhante a esta.

meu-projeto/
main.go
go.mod
go.sum
migrations.db
migrations/
00001_criar_produtos.sql

Se executarmos o mesmo comando novamente, o goose verificará que a primeira migration já foi aplicada e não tentará executá-la outra vez. Esse comportamento evita erros provocados pela repetição de comandos como CREATE TABLE.


Também não precisamos adicionar IF NOT EXISTS em todas as migrations apenas para impedir que sejam executadas repetidamente. O próprio goose controla as versões aplicadas, embora as operações SQL continuem precisando respeitar as regras e restrições do banco.


Como o goose executa as alterações?​


Por padrão, o goose executa as instruções de uma migration SQL dentro de uma transação. Isso permite que as alterações da migration e o registro de sua versão sejam tratados em conjunto, evitando que uma falha deixe apenas parte daquela migration confirmada.


Se uma migration possuir vários comandos SQL e algum deles falhar, a transação poderá ser desfeita, preservando a estrutura anterior do banco. No SQLite, comandos como CREATE TABLE e ALTER TABLE podem participar de transações, então esse mecanismo funciona para as alterações que estamos estudando.


Isso não significa que todas as migrations de um projeto sejam executadas como uma única transação. O goose normalmente controla cada migration separadamente, confirmando as versões concluídas antes de continuar para a próxima.


O goose também possui uma anotação chamada -- +goose NO TRANSACTION, que permite executar uma migration sem uma transação gerenciada pela ferramenta. Entretanto, não precisamos dessa configuração nos exemplos atuais, porque queremos manter as garantias oferecidas pela execução transacional.



Alterando tabelas com migrations​


Depois que uma tabela já foi criada, podemos precisar modificar sua estrutura sem apagar os registros existentes. Esse é um dos principais motivos para trabalhar com migrations, porque cada mudança pode ser registrada em um novo arquivo e aplicada somente nos bancos que ainda não possuem aquela versão.


Imagine que nossa tabela produtos já contém registros, mas agora precisamos adicionar uma descrição para cada produto. Em vez de alterar o arquivo 00001_criar_produtos.sql, vamos criar uma nova migration responsável apenas por adicionar essa coluna.


Isso permite preservar o histórico do banco. A primeira migration continua representando a estrutura inicial, enquanto a segunda representa a mudança que foi adicionada posteriormente.



Adicionando uma coluna​


Vamos criar uma segunda migration usando o goose.

# Cria a migration responsável pela descrição.
goose -dir migrations -s create adicionar_descricao sql

Como já existe uma migration com a versão 00001, o goose deverá criar um novo arquivo com a próxima numeração sequencial.

migrations/
00001_criar_produtos.sql
00002_adicionar_descricao.sql

Agora podemos definir os comandos necessários para adicionar e remover a coluna descricao.

-- +goose Up

-- Adiciona a coluna de descrição.
ALTER TABLE produtos
ADD COLUMN descricao TEXT;

-- +goose Down

-- Remove a coluna de descrição.
ALTER TABLE produtos
DROP COLUMN descricao;

O comando ALTER TABLE produtos ADD COLUMN modifica a estrutura da tabela existente, adicionando uma coluna chamada descricao. Como usamos TEXT sem a restrição NOT NULL, os registros anteriores poderão continuar com NULL nesse campo.


Já a operação Down remove a coluna por meio de ALTER TABLE produtos DROP COLUMN. Esse comando é suportado pelo SQLite a partir da versão 3.35.0, mas possui restrições. Por exemplo, não podemos remover diretamente uma coluna que faz parte da chave primária ou que ainda está sendo referenciada por determinados índices, restrições, views ou triggers.


Também precisamos considerar que remover a coluna apaga os valores que estavam armazenados nela. Se executarmos o Down e depois aplicarmos novamente o Up, a coluna será recriada, mas suas descrições anteriores não serão recuperadas automaticamente.


Para aplicar essa nova versão, executamos novamente o comando up.

# Aplica as migrations que ainda não foram executadas.
goose -dir migrations sqlite3 ./migrations.db up

Como a primeira migration já foi aplicada, o goose executará apenas a segunda. A tabela produtos continuará armazenando seus registros anteriores, mas agora também possuirá a coluna descricao.


Adicionando uma coluna obrigatória​


Agora imagine que precisamos adicionar um campo chamado ativo, responsável por indicar se o produto está disponível na aplicação. Queremos que esse campo seja obrigatório e que todos os produtos existentes comecem como ativos.


Podemos criar uma terceira migration.

# Cria a migration do campo ativo.
goose -dir migrations -s create adicionar_ativo sql

O novo arquivo terá uma estrutura semelhante a esta.

migrations/
00001_criar_produtos.sql
00002_adicionar_descricao.sql
00003_adicionar_ativo.sql

Vamos definir seu conteúdo.

-- +goose Up

-- Adiciona o estado do produto com valor padrão.
ALTER TABLE produtos
ADD COLUMN ativo INTEGER NOT NULL DEFAULT 1 CHECK (ativo IN (0, 1));

-- +goose Down

-- Remove a coluna ativo.
ALTER TABLE produtos
DROP COLUMN ativo;

Nesse exemplo, adicionamos uma coluna INTEGER com a restrição NOT NULL, impedindo que ela receba NULL. Também definimos DEFAULT 1, fazendo com que os produtos existentes e os novos registros que omitirem essa coluna tenham o valor padrão 1.


O valor padrão é necessário porque estamos adicionando uma coluna obrigatória a uma tabela que pode possuir registros. No SQLite, ALTER TABLE ... ADD COLUMN não permite adicionar uma coluna NOT NULL sem um valor padrão diferente de NULL, já que os registros anteriores precisam continuar atendendo à nova restrição.


Também adicionamos CHECK (ativo IN (0, 1)), que impede valores diferentes de 0 e 1. Dessa forma, a própria estrutura do banco controla quais valores são aceitos para representar o estado do produto.


Depois de salvar o arquivo, podemos aplicar a alteração usando o mesmo comando.

# Executa a nova versão.
goose -dir migrations sqlite3 ./migrations.db up

A tabela passará a possuir as colunas id, nome, preco_centavos, estoque, descricao e ativo. Os registros anteriores serão preservados, respeitando as regras definidas durante a alteração.


Renomeando uma coluna​


Outra alteração comum é modificar o nome de uma coluna. O SQLite oferece o comando ALTER TABLE ... RENAME COLUMN, que permite realizar essa mudança sem precisar apagar e criar novamente a tabela.


Por exemplo, imagine que precisamos renomear uma coluna chamada descricao para detalhes.

-- Renomeia uma coluna existente.
ALTER TABLE produtos
RENAME COLUMN descricao TO detalhes;

Esse comando altera o nome da coluna, mantendo os valores armazenados. O SQLite também tenta atualizar referências relacionadas em índices, triggers e views, respeitando as regras e limitações da alteração.


Se quisermos registrar essa mudança em uma migration, podemos definir o comando contrário na operação Down.

-- +goose Up

-- Renomeia descricao para detalhes.
ALTER TABLE produtos
RENAME COLUMN descricao TO detalhes;

-- +goose Down

-- Retorna ao nome anterior.
ALTER TABLE produtos
RENAME COLUMN detalhes TO descricao;

Esse exemplo mostra como seria feita uma migration de renomeação, mas não faz parte das três versões que estamos executando no projeto. Caso seja adicionada como uma nova migration, também será necessário atualizar as consultas e structs da aplicação que ainda dependem do nome anterior.


Isso acontece porque modificar a estrutura do banco não modifica automaticamente o código Go. Se uma consulta continuar solicitando a coluna descricao depois que ela for renomeada para detalhes, o SQLite retornará um erro informando que a coluna não existe.


Criando índices com migrations​


As migrations também podem ser usadas para criar índices, que são estruturas mantidas pelo banco para ajudar na localização e ordenação de registros. Imagine que nossa aplicação realiza muitas consultas buscando produtos pelo nome. Podemos criar um índice para essa coluna, permitindo que o SQLite tenha uma estrutura adicional que pode ser escolhida durante a execução dessas consultas.


Vamos criar uma quarta migration.

# Cria uma migration para o índice de produtos.
goose -dir migrations -s create criar_indice_nome sql

O arquivo gerado poderá ser chamado 00004_criar_indice_nome.sql.

-- +goose Up

-- Cria um índice para a coluna nome.
CREATE INDEX idx_produtos_nome
ON produtos(nome);

-- +goose Down

-- Remove o índice criado.
DROP INDEX idx_produtos_nome;

O comando CREATE INDEX cria um índice chamado idx_produtos_nome, associado à coluna nome da tabela produtos. O SQLite pode usar essa estrutura para melhorar determinadas consultas, como buscas por igualdade ou operações de ordenação compatíveis com o índice.


Isso não significa que qualquer consulta envolvendo nome será automaticamente mais rápida. O SQLite possui um mecanismo de planejamento de consultas que decide quando um índice deve ser usado, considerando diferentes fatores relacionados à operação.


Também precisamos lembrar que índices ocupam espaço e precisam ser mantidos durante inserções, atualizações e remoções. Por isso, normalmente criamos índices de acordo com as necessidades reais de consulta, evitando adicionar estruturas que não oferecem benefícios ao projeto.


A operação Down remove o índice usando DROP INDEX, sem apagar os registros da tabela. Isso permite desfazer a alteração estrutural preservando os dados dos produtos.


Limitações do ALTER TABLE no SQLite​


O SQLite oferece suporte a diferentes operações de alteração de tabelas, mas nem toda mudança pode ser realizada diretamente por um único comando ALTER TABLE. O suporte também depende da versão do SQLite, então precisamos verificar quais operações estão disponíveis no ambiente em que o banco será executado.


Algumas mudanças comuns incluem adicionar colunas, renomear tabelas, renomear colunas e remover colunas. Já alterações mais complexas, como modificar determinados tipos, reorganizar chaves ou alterar restrições existentes, podem exigir procedimentos adicionais.


Em versões recentes do SQLite, algumas alterações de restrições passaram a ter suporte direto, mas isso não elimina a necessidade de reconstruir tabelas em situações que não são atendidas pelos comandos disponíveis. Por isso, não devemos assumir que qualquer alteração permitida em PostgreSQL ou MySQL também funcionará da mesma maneira no SQLite.


Quando uma mudança não pode ser realizada diretamente, uma estratégia comum é criar uma nova tabela com a estrutura desejada, copiar os registros da tabela anterior, remover a estrutura antiga e renomear a nova tabela.

Tabela original
|
| Cria uma nova tabela
v
Nova estrutura
|
| Copia os registros
v
Dados preservados
|
| Remove a tabela antiga
v
Nova tabela assume o nome original

Esse procedimento precisa ser planejado com cuidado, principalmente quando existem chaves estrangeiras, índices, triggers ou views relacionados à tabela. O SQLite possui uma sequência recomendada para reconstruções, incluindo verificações de integridade e cuidados com PRAGMA foreign_keys.


Como o goose executa migrations dentro de transações por padrão, também precisamos considerar que determinadas configurações do SQLite, como a alteração de PRAGMA foreign_keys, não podem ser modificadas enquanto uma transação está ativa. Por isso, reconstruções que envolvem relacionamentos precisam seguir o procedimento recomendado pela documentação oficial, em vez de simplesmente executar DROP TABLE e criar outra estrutura.


A documentação do SQLite apresenta esse processo em Making Other Kinds Of Table Schema Changes.



Versionando mudanças no banco​


Cada migration possui uma versão que identifica sua posição no histórico de alterações do banco. Esse controle permite saber quais mudanças já foram executadas e quais ainda precisam ser aplicadas, evitando que a mesma estrutura seja criada ou modificada repetidamente.


No nosso exemplo, temos quatro migrations que representam momentos diferentes do desenvolvimento.

migrations/
00001_criar_produtos.sql
00002_adicionar_descricao.sql
00003_adicionar_ativo.sql
00004_criar_indice_nome.sql

A primeira cria a tabela produtos, a segunda adiciona a coluna descricao, a terceira adiciona o campo ativo e a quarta cria um índice para a coluna nome. Cada arquivo depende da estrutura criada pelas versões anteriores, então a ordem de execução precisa ser respeitada.


O goose controla essas versões por meio de uma tabela interna chamada goose_db_version, que é criada no banco durante a inicialização do controle de migrations. Essa tabela armazena informações sobre as versões aplicadas, permitindo que a ferramenta determine quais alterações ainda precisam ser executadas.


Diferentemente de uma tabela comum da aplicação, goose_db_version pertence ao mecanismo de controle da ferramenta. Não precisamos inserir registros manualmente nela, porque o próprio goose atualiza seu histórico quando as migrations são aplicadas ou desfeitas.


Consultando as migrations aplicadas​


O goose oferece o comando status, que permite consultar quais migrations já foram executadas e quais continuam pendentes.

# Consulta o estado das migrations.
goose -dir migrations sqlite3 ./migrations.db status

O comando procura os arquivos presentes no diretório migrations e compara suas versões com o histórico armazenado no banco. As migrations aplicadas aparecem com informações sobre sua execução, enquanto as que ainda não foram executadas são identificadas como pendentes.


Também podemos consultar a versão atual do banco usando version.

# Mostra a versão atual das migrations.
goose -dir migrations sqlite3 ./migrations.db version

Se as quatro migrations do exemplo tiverem sido aplicadas corretamente, a versão atual será 4. Isso indica que o banco já possui as alterações correspondentes até a quarta versão.


O número da versão não representa a versão do SQLite. Ele representa apenas o estágio do histórico de migrations do projeto. A versão do próprio mecanismo SQLite é uma informação diferente, que pode ser consultada por meio da função sqlite_version().


Executando apenas uma migration​


Normalmente, o comando up executa todas as migrations pendentes, mas o goose também permite aplicar apenas a próxima versão.

# Executa somente a próxima migration pendente.
goose -dir migrations sqlite3 ./migrations.db up-by-one

O comando up-by-one procura a próxima migration que ainda não foi aplicada e executa somente essa versão. Isso pode ser interessante durante o desenvolvimento, principalmente quando queremos observar como a estrutura muda depois de cada alteração.


Também podemos executar migrations até uma versão específica usando up-to.

# Aplica as migrations até a versão 3.
goose -dir migrations sqlite3 ./migrations.db up-to 3

Nesse exemplo, o goose aplica as migrations pendentes até a versão 3, sem executar versões posteriores. Se o banco já estiver nessa versão, não haverá nenhuma nova alteração para aplicar.


Esse controle pode ajudar quando diferentes versões da aplicação dependem de estruturas específicas do banco, embora seja necessário garantir que o código executado seja compatível com a versão escolhida.


Desfazendo uma migration​


O goose também oferece o comando down, que permite executar a operação de reversão da última migration aplicada.

# Desfaz a última migration aplicada.
goose -dir migrations sqlite3 ./migrations.db down

Se a última migration aplicada for 00004_criar_indice_nome.sql, o goose executará o comando definido depois de -- +goose Down, removendo o índice criado anteriormente.


Depois disso, a versão atual voltará para 3, enquanto as outras migrations continuarão aplicadas. O comando não desfaz automaticamente todo o histórico, apenas a última versão aplicada.


Também podemos reverter migrations até uma versão específica.

# Desfaz migrations até retornar à versão 2.
goose -dir migrations sqlite3 ./migrations.db down-to 2

Nesse exemplo, as versões posteriores à 2 serão desfeitas, respeitando a ordem inversa de execução. Isso significa que a migration do índice será revertida antes da migration responsável pelo campo ativo.


O goose também oferece comandos como redo, que desfaz e reaplica a última migration, e reset, que tenta reverter todas as migrations. Esses comandos precisam de cuidado porque podem executar operações destrutivas, dependendo das instruções definidas nos blocos Down.


Desfazer uma migration não significa recuperar automaticamente os dados anteriores. Se uma operação remover uma tabela ou uma coluna, os registros associados poderão ser perdidos. Em bancos que armazenam informações importantes, precisamos avaliar o impacto antes de executar comandos de reversão.


Por que não devemos modificar migrations antigas?​


Depois que uma migration foi aplicada em um banco compartilhado ou em produção, normalmente não devemos modificar seu conteúdo. Isso acontece porque outros bancos podem já ter executado a versão original, enquanto ambientes novos executariam a versão modificada.


Imagine que a versão 00001 criou uma tabela contendo apenas id, nome, preco_centavos e estoque. Se modificarmos esse arquivo para adicionar diretamente a coluna descricao, os bancos que já executaram a primeira versão não receberão essa alteração, porque o goose continuará considerando a migration 00001 como aplicada.


Enquanto isso, um banco criado do zero executaria a nova definição da primeira migration. Dessa forma, dois bancos poderiam ter históricos de versões aparentemente iguais, mas estruturas diferentes.


Para evitar esse problema, o ideal é manter as migrations já compartilhadas sem alterações e criar novas versões sempre que uma mudança precisar ser aplicada. Isso preserva o histórico e permite que os bancos avancem pela mesma sequência de modificações.


O goose registra as versões aplicadas, mas não devemos depender desse mecanismo como uma verificação automática de que arquivos antigos nunca foram modificados. A manutenção correta do histórico também depende da organização do projeto e do controle de versão dos arquivos.


Versionando migrations com Git​


Além de controlar as versões aplicadas no banco, também precisamos manter os arquivos SQL no controle de versão do projeto. Ferramentas como Git permitem registrar quando cada migration foi criada e acompanhar as mudanças feitas no código e na estrutura do banco.


Por exemplo, podemos adicionar os arquivos de migrations ao repositório junto com as alterações da aplicação que dependem deles.

# Adiciona os arquivos de migrations ao Git.
git add migrations/

# Registra as alterações.
git commit -m "Adiciona migrations de produtos"

Dessa forma, quando outra pessoa obtiver o código do projeto, também terá acesso aos arquivos responsáveis por criar e modificar a estrutura do banco.


O controle do Git e o controle do goose possuem responsabilidades diferentes. O Git registra as mudanças realizadas nos arquivos do projeto, enquanto o goose registra quais migrations foram efetivamente aplicadas a determinado banco.


Isso significa que adicionar uma migration ao Git não executa automaticamente a alteração no SQLite. O arquivo precisa ser aplicado por meio do goose ou de outro mecanismo configurado pela aplicação.



Organizando arquivos SQL do projeto​


Conforme a quantidade de migrations aumenta, precisamos manter os arquivos organizados para facilitar a identificação das alterações e evitar que diferentes partes do projeto dependam de comandos SQL espalhados pelo código.


Uma estrutura simples consiste em manter as migrations em um diretório próprio, separado do código Go responsável pelas regras da aplicação e pelas operações de acesso aos dados.

meu-projeto/
main.go
go.mod
go.sum
migrations.db

migrations/
00001_criar_produtos.sql
00002_adicionar_descricao.sql
00003_adicionar_ativo.sql
00004_criar_indice_nome.sql

Nesse exemplo, o diretório migrations armazena exclusivamente os arquivos responsáveis por modificar a estrutura do banco. O arquivo migrations.db representa o banco SQLite que receberá as alterações, enquanto main.go contém o código responsável pela execução da aplicação.


Os nomes dos arquivos descrevem a responsabilidade de cada migration. Isso facilita identificar quais versões criaram tabelas, adicionaram colunas ou modificaram determinadas estruturas, sem precisar abrir todos os arquivos para entender o histórico.


Também podemos criar diretórios separados para consultas SQL maiores, dados iniciais ou outros arquivos relacionados ao banco. Entretanto, as migrations precisam continuar organizadas de maneira que a ferramenta consiga localizar e executar suas versões corretamente.


Separando migrations de consultas SQL​


As migrations representam alterações na estrutura ou nos dados que precisam ser executadas de maneira controlada e versionada. Já os comandos responsáveis pelo funcionamento normal da aplicação, como inserções, buscas, atualizações e remoções, não precisam ser armazenados como migrations.


Por exemplo, um comando usado para buscar produtos pelo identificador faz parte das operações normais da aplicação.

// Busca um produto pelo identificador.
err := db.QueryRow(`
SELECT id, nome, preco_centavos
FROM produtos
WHERE id = ?
`, id).Scan(&produto.ID, &produto.Nome, &produto.PrecoCentavos)

Esse comando será executado sempre que a aplicação precisar consultar um produto. Ele não modifica a estrutura do banco e não representa uma nova versão do esquema, então normalmente pertence à camada responsável pelo acesso aos dados.


Já comandos como CREATE TABLE, ALTER TABLE e CREATE INDEX podem fazer parte das migrations, porque representam mudanças persistentes na estrutura do banco.


Essa separação evita confundir operações que fazem parte do funcionamento diário da aplicação com alterações que precisam acontecer apenas quando uma nova versão do banco é aplicada.


Separando migrations de dados iniciais​


Também precisamos diferenciar migrations responsáveis pela estrutura do banco de comandos usados para cadastrar informações iniciais. Esses dados são conhecidos como seeds, que representam registros necessários para preparar determinado ambiente.


Imagine que uma aplicação precisa começar com algumas categorias de produtos cadastradas. Podemos ter um arquivo SQL responsável por inserir essas informações.

-- Insere categorias iniciais.
INSERT INTO categorias (nome)
VALUES
('Informática'),
('Periféricos'),
('Acessórios');

Esse tipo de comando pode fazer parte de uma migration quando os registros são necessários para o funcionamento de uma nova versão da aplicação. Entretanto, também podemos organizar seeds separadamente, principalmente quando representam dados de demonstração ou informações específicas de ambientes de desenvolvimento e teste.


A escolha depende da responsabilidade desses dados. Registros que precisam existir em todos os ambientes podem fazer parte de uma mudança versionada, enquanto dados temporários para testes normalmente não devem ser misturados com migrations que alteram a estrutura de produção.



Executando migrations diretamente pelo Go​


Até agora, executamos as migrations pelo terminal usando o executável do goose. Entretanto, também podemos chamar a biblioteca diretamente na aplicação Go, permitindo aplicar as versões pendentes durante um processo de inicialização ou em um comando específico do projeto.


Para isso, precisamos adicionar o pacote github.com/pressly/goose/v3 como dependência da aplicação.

# Adiciona a biblioteca goose ao projeto.
go get github.com/pressly/goose/v3

Depois disso, podemos importar o pacote e chamar sua função Up(), que recebe um *sql.DB e o diretório que contém as migrations.

package main

import (
"database/sql"
"fmt"
"log"

"github.com/pressly/goose/v3"
_ "modernc.org/sqlite"
)

func main() {
if err := executar(); err != nil {
log.Fatal(err)
}
}

func executar() error {
// Abre o banco SQLite.
db, err := sql.Open("sqlite", "migrations.db")
if err != nil {
return err
}

defer db.Close()

// Verifica se o banco está acessível.
if err := db.Ping(); err != nil {
return err
}

// Configura o dialeto SQLite para o goose.
if err := goose.SetDialect("sqlite3"); err != nil {
return err
}

// Executa as migrations pendentes.
if err := goose.Up(db, "migrations"); err != nil {
return fmt.Errorf("erro ao executar migrations: %w", err)
}

fmt.Println("Estrutura do banco atualizada!")

return nil
}

Nesse exemplo, abrimos o banco usando database/sql e o driver modernc.org/sqlite, da mesma maneira que fizemos nos tópicos anteriores. Depois, chamamos goose.SetDialect("sqlite3") para informar à biblioteca qual dialeto SQL será usado durante o gerenciamento das migrations.


O dialeto representa as características do banco que a ferramenta precisa considerar ao executar seus comandos internos. Essa configuração não precisa ser igual ao nome registrado pelo driver no database/sql, porque o dialeto do goose e o driver responsável pela conexão possuem responsabilidades diferentes.


Em seguida, chamamos goose.Up(db, "migrations"), que procura os arquivos SQL no diretório informado e executa as versões pendentes. Se alguma migration falhar, a função retorna um erro, permitindo que a aplicação interrompa sua inicialização em vez de continuar trabalhando com uma estrutura incompleta.


Esse exemplo considera que o diretório migrations está disponível no sistema de arquivos, em relação ao diretório de trabalho usado para executar a aplicação. Se o programa for executado a partir de outro local, será necessário informar o caminho correto.


Também precisamos evitar que várias instâncias da aplicação tentem executar alterações estruturais simultaneamente sem uma estratégia de coordenação. Em projetos maiores, as migrations normalmente são executadas em uma etapa controlada de implantação, antes que a nova versão da aplicação comece a receber operações.



Incorporando os arquivos SQL ao executável​


O Go oferece o pacote embed, que permite incluir arquivos do projeto diretamente no executável durante a compilação. Esse recurso pode ser interessante quando queremos distribuir a aplicação sem depender de um diretório externo contendo os arquivos SQL.


Podemos incorporar as migrations usando a diretiva //go:embed, que informa ao compilador quais arquivos devem fazer parte do programa.

package main

import (
"database/sql"
"embed"
"fmt"
"log"

"github.com/pressly/goose/v3"
_ "modernc.org/sqlite"
)

//go:embed migrations/*.sql
var migrations embed.FS

func main() {
if err := executar(); err != nil {
log.Fatal(err)
}
}

func executar() error {
// Abre o banco SQLite.
db, err := sql.Open("sqlite", "migrations.db")
if err != nil {
return err
}

defer db.Close()

// Verifica se o banco está acessível.
if err := db.Ping(); err != nil {
return err
}

// Define os arquivos incorporados como origem das migrations.
goose.SetBaseFS(migrations)

// Configura o dialeto SQLite.
if err := goose.SetDialect("sqlite3"); err != nil {
return err
}

// Executa as migrations incorporadas.
if err := goose.Up(db, "migrations"); err != nil {
return fmt.Errorf("erro ao executar migrations: %w", err)
}

fmt.Println("Migrations executadas com sucesso!")

return nil
}

Nesse exemplo, a variável migrations possui o tipo embed.FS, que representa um sistema de arquivos somente para leitura contendo os arquivos incorporados. A diretiva //go:embed migrations/*.sql informa que todos os arquivos com extensão .sql encontrados diretamente no diretório migrations devem ser incluídos no executável durante a compilação.


O método goose.SetBaseFS() configura a origem dos arquivos que o goose deve ler. Depois disso, goose.Up() consegue encontrar as migrations incorporadas, mesmo que os arquivos SQL não estejam disponíveis separadamente no diretório em que o programa foi executado.


Observe que continuamos passando "migrations" para goose.Up(), porque o embed preserva a estrutura de diretórios informada na diretiva. A diferença é que os arquivos agora fazem parte do executável, em vez de serem lidos diretamente do disco durante a execução.


Podemos compilar o projeto normalmente.

# Compila a aplicação com os arquivos SQL incorporados.
go build -o minha-aplicacao .

O executável produzido conterá o código Go e os arquivos SQL das migrations. Isso permite distribuir uma versão da aplicação com as alterações necessárias para preparar sua estrutura de banco.


Entretanto, o arquivo SQLite continua sendo externo e persistente. Incorporar as migrations não significa incorporar o banco de dados, porque os registros precisam continuar disponíveis e modificáveis depois que o programa for compilado.


Também precisamos considerar que qualquer alteração realizada nos arquivos SQL depois da compilação exige gerar um novo executável para que a versão incorporada seja atualizada.


Cuidados com migrations em produção​


Quando uma aplicação já possui dados reais, precisamos tratar alterações estruturais com mais cuidado. Uma migration que funciona corretamente em um banco vazio pode apresentar problemas quando precisa modificar tabelas com muitos registros, restrições existentes ou relacionamentos com outras tabelas.


Antes de aplicar uma mudança importante, normalmente precisamos verificar se o SQL é compatível com a versão do SQLite em execução, se os registros existentes atendem às novas restrições e se a operação pode provocar perda de informações.


Também é recomendado manter backups antes de mudanças destrutivas. O fato de uma migration possuir uma operação Down não significa que todos os dados poderão ser restaurados, principalmente quando a mudança envolve exclusão de colunas, remoção de tabelas ou transformações que descartam informações.


Outro cuidado está na compatibilidade entre a versão da aplicação e a estrutura do banco. Se uma migration renomear ou remover uma coluna que ainda é acessada pelo código antigo, a aplicação poderá começar a apresentar erros. Por isso, em sistemas que precisam permanecer disponíveis durante atualizações, algumas mudanças são divididas em etapas, permitindo que versões antigas e novas do código convivam temporariamente com a mesma estrutura.


Por fim, as migrations precisam ser executadas sobre o banco correto. Como SQLite trabalha com arquivos, informar um caminho relativo diferente pode fazer com que o goose crie ou modifique outro banco, sem alterar aquele que a aplicação realmente acessa. Manter o caminho do banco claramente definido ajuda a evitar esse problema.