Study guide
The schema is code, and it has a history.
What database migrations are, the problem they solve, how to start them in a new project, what the database must look like before the first one, how to avoid conflicts in a team and how to run them in production, with Flyway (Java/Spring), Prisma (TypeScript) and Alembic (Python/FastAPI).
O que é uma migrationWhat a migration is
Uma migration é um arquivo versionado que descreve uma mudança no esquema do banco: criar uma tabela, adicionar uma coluna, criar um índice, renomear algo, mover dados. Os arquivos ficam no repositório, ao lado do código, e são aplicados em ordem por uma ferramenta que lembra, dentro do próprio banco, quais já rodaram.A migration is a versioned file that describes one change to the database schema: create a table, add a column, create an index, rename something, move data. The files live in the repository next to the code and are applied in order by a tool that remembers, inside the database itself, which ones have already run.
Pense no Git, só que para o banco: cada migration é um commit do esquema. O estado atual do banco é a soma de todas as migrations aplicadas, da primeira até a última. Qualquer banco (o seu notebook, o CI, staging, produção) que rodar a mesma sequência chega ao mesmo esquema.Think of Git, but for the database: each migration is a commit of the schema. The current state of the database is the sum of every applied migration, from the first to the last. Any database (your laptop, CI, staging, production) that runs the same sequence ends up with the same schema.
Qual problema resolveWhat problem it solves
Sem migrations, o esquema muda "na mão": alguém roda um ALTER TABLE no banco de produção, outro esquece de rodar no staging, o colega novo não sabe quais comandos rodar para montar o banco local. O resultado tem nome: drift, quando bancos que deveriam ser iguais ficam diferentes e ninguém sabe exatamente como.Without migrations, the schema changes "by hand": someone runs an ALTER TABLE on the production database, someone else forgets to run it on staging, the new colleague does not know which commands to run to build the local database. The result has a name: drift, when databases that should be identical end up different and nobody knows exactly how.
| Sem migrationsWithout migrations | Com migrationsWith migrations |
|---|---|
| SQL solto em chat, wiki ou na memória de alguémSQL scattered in chat, a wiki or someone's memory | cada mudança é um arquivo no Git, revisado em PReach change is a file in Git, reviewed in a PR |
| "funciona na minha máquina" porque o seu banco tem uma coluna a mais"works on my machine" because your database has one extra column | todo ambiente roda a mesma sequência e chega ao mesmo esquemaevery environment runs the same sequence and reaches the same schema |
| deploy do código novo antes da coluna existir: erro 500deploying new code before the column exists: 500 error | o deploy aplica as migrations pendentes antes (ou junto) de subir o códigothe deploy applies pending migrations before (or together with) the code |
| ninguém sabe em que versão o banco de produção estánobody knows which version the production database is at | a tabela de histórico responde: versão, data, quem e checksumthe history table answers: version, date, who and checksum |
| montar um banco novo exige um dump de produçãobuilding a new database needs a production dump | banco vazio + migrate = esquema completoempty database + migrate = full schema |
Duas filosofias: SQL escrito ou esquema declarativoTwo philosophies: hand-written SQL or a declarative schema
Todas as ferramentas acabam executando SQL, mas diferem em quem escreve esse SQL:Every tool ends up running SQL, but they differ in who writes that SQL:
- Versionada por SQL (Flyway, Liquibase): você escreve o
ALTER TABLE. Controle total, nenhuma mágica, mas é você quem mantém o código das entidades e o SQL em sincronia.SQL-versioned (Flyway, Liquibase): you write theALTER TABLE. Full control, no magic, but you keep the entity code and the SQL in sync. - Gerada a partir do modelo (Prisma, Alembic com autogenerate, TypeORM, Django): você muda o modelo (o
schema.prismaou as classes do SQLAlchemy), a ferramenta compara com o banco e gera o SQL da diferença. Rápido, mas o SQL gerado precisa ser lido antes do commit: renomear uma coluna pode virar "apagar e criar", perdendo dados.Generated from the model (Prisma, Alembic with autogenerate, TypeORM, Django): you change the model (theschema.prismaor the SQLAlchemy classes), the tool diffs it against the database and generates the SQL for the difference. Fast, but the generated SQL must be read before committing: renaming a column can turn into "drop and create", losing data.
Como a ferramenta funcionaHow the tool works
Flyway, Prisma e Alembic seguem o mesmo algoritmo. Entendê-lo uma vez explica as três:Flyway, Prisma and Alembic follow the same algorithm. Understanding it once explains all three:
| Flyway | Prisma | Alembic | |
|---|---|---|---|
| Tabela de históricoHistory table | flyway_schema_history | _prisma_migrations | alembic_version |
| O que guardaWhat it stores | uma linha por migration: versão, descrição, checksum, data, sucessoone row per migration: version, description, checksum, date, success | uma linha por migration: nome, checksum, início, fim, logsone row per migration: name, checksum, start, finish, logs | só uma linha: o id da revisão atual (a "head")just one row: the id of the current revision (the "head") |
| Como ordenaHow it orders | pelo número da versão no nome (V3 < V10)by the version number in the name (V3 < V10) | pelo timestamp no nome da pastaby the timestamp in the folder name | por uma lista encadeada: cada arquivo aponta para o anterior (down_revision)by a linked list: each file points to the previous one (down_revision) |
| Detecta arquivo editadoDetects an edited file | sim (checksum)yes (checksum) | sim (checksum)yes (checksum) | não: confia em vocêno: it trusts you |
| Trava contra execuções simultâneasLock against simultaneous runs | simyes | sim (advisory lock)yes (advisory lock) | não: rode uma vez só, num passo do deployno: run it once, in a deploy step |
Começando do zeroStarting from scratch
Como o banco precisa estarWhat the database must look like
Migrations cuidam do que fica dentro do banco (tabelas, índices, constraints, dados de referência). O que está em volta normalmente vem antes, por infraestrutura (Docker Compose, Terraform, o painel do provedor):Migrations take care of what lives inside the database (tables, indexes, constraints, reference data). What is around it usually comes first, from infrastructure (Docker Compose, Terraform, the provider's console):
- O servidor e o database existem (
CREATE DATABASE app). Flyway e Alembic não criam o database; oprisma migrate devcria em desenvolvimento, mas em produção você não deve depender disso.The server and the database exist (CREATE DATABASE app). Flyway and Alembic do not create the database;prisma migrate devdoes in development, but in production you should not rely on it. - Um usuário com permissão de DDL (criar e alterar tabelas) para rodar as migrations. Em produção, o ideal é separar: um usuário de migration (dono das tabelas) e um usuário da aplicação só com
SELECT/INSERT/UPDATE/DELETE.A user with DDL permission (create and alter tables) to run the migrations. In production, ideally separate them: a migration user (owner of the tables) and an application user with onlySELECT/INSERT/UPDATE/DELETE. - O schema vazio (sem tabelas) num projeto novo. A ferramenta cria a tabela de histórico sozinha na primeira execução.An empty schema (no tables) in a new project. The tool creates its history table on its own on the first run.
- Extensões como
pgcryptooupostgispodem ir numa migration (CREATE EXTENSION IF NOT EXISTS) se o usuário tiver permissão; em bancos gerenciados às vezes só o admin pode, aí ficam na infraestrutura.Extensions such aspgcryptoorpostgiscan go in a migration (CREATE EXTENSION IF NOT EXISTS) if the user is allowed; on managed databases sometimes only the admin can, so they stay in the infrastructure.
# docker-compose.yml (dev): the server and an empty database, nothing else
services:
db:
image: postgres:17
environment:
POSTGRES_DB: app # the database exists, with no tables
POSTGRES_USER: app
POSTGRES_PASSWORD: dev
ports: ["5432:5432"]
A primeira migration tem todas as tabelas?Does the first migration have every table?
A primeira migration contém tudo o que existe no momento em que você a cria, e nada além. Num projeto realmente novo, isso costuma ser pouco: users e talvez mais uma ou duas tabelas. Você não precisa (nem deve) desenhar o banco inteiro do futuro no V1.The first migration contains everything that exists at the moment you create it, and nothing more. In a truly new project, that is usually little: users and maybe one or two more tables. You do not need to (and should not) design the whole future database in V1.
Daí em diante, cada migration é um delta: só a mudança daquela tarefa. Feature de pedidos? V2__create_orders.sql. Precisa do telefone do usuário? V3__add_users_phone.sql. Nunca volte ao V1 para "encaixar" a coluna nova, nem num projeto que ainda não está em produção, assim que alguém além de você já rodou aquele arquivo.From then on, each migration is a delta: only the change for that task. Orders feature? V2__create_orders.sql. Need the user's phone? V3__add_users_phone.sql. Never go back to V1 to "fit in" the new column, even in a project not yet in production, as soon as anyone other than you has run that file.
Banco que já existe: baselineAn existing database: baseline
Se o projeto já tem um banco em produção criado na mão (ou com ddl-auto=update, create_all(), synchronize: true), você não pode rodar um V1 com CREATE TABLE users: a tabela já existe e vai dar erro. O caminho é o baseline:If the project already has a production database created by hand (or with ddl-auto=update, create_all(), synchronize: true), you cannot run a V1 with CREATE TABLE users: the table exists and it will fail. The way out is a baseline:
- Gere o SQL do esquema atual (
pg_dump --schema-onlyou o diff da ferramenta) e salve como a primeira migration.Generate the SQL of the current schema (pg_dump --schema-onlyor the tool's diff) and save it as the first migration. - Nos bancos que já têm essas tabelas, marque essa migration como aplicada sem executá-la.On databases that already have those tables, mark that migration as applied without running it.
- Bancos novos (dev, CI) rodam o baseline normalmente e chegam ao mesmo esquema.New databases (dev, CI) run the baseline normally and reach the same schema.
| FerramentaTool | Marcar o baseline como aplicadoMark the baseline as applied |
|---|---|
| Flyway | flyway baseline ouor spring.flyway.baseline-on-migrate=true (versão do baseline: baseline-version)(baseline version: baseline-version) |
| Prisma | npx prisma migrate resolve --applied 0_init |
| Alembic | alembic stamp <revision> (grava a revisão sem rodar o upgrade)(records the revision without running upgrade) |
spring.jpa.hibernate.ddl-auto=validate (ou none), nada de Base.metadata.create_all() no startup do FastAPI, synchronize: false no TypeORM. Duas fontes de verdade para o esquema é exatamente o drift que as migrations vieram resolver.Turn off automatic schema generation as soon as you adopt migrations: spring.jpa.hibernate.ddl-auto=validate (or none), no Base.metadata.create_all() at FastAPI startup, synchronize: false in TypeORM. Two sources of truth for the schema is exactly the drift migrations came to solve.Java + Spring: FlywayJava + Spring: Flyway
O Flyway é a escolha mais comum em Spring Boot. Você escreve SQL puro, e o Spring Boot roda o Flyway sozinho no startup, antes do Hibernate/JPA subir.Flyway is the most common choice in Spring Boot. You write plain SQL, and Spring Boot runs Flyway on its own at startup, before Hibernate/JPA comes up.
1. Dependências1. Dependencies
<!-- pom.xml -->
<!-- Spring Boot 4: the dedicated starter. Spring Boot 3: org.flywaydb:flyway-core instead. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-flyway</artifactId>
</dependency>
<!-- Since Flyway 10, each database has its own module -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
2. Configuração2. Configuration
# src/main/resources/application.yml
spring:
datasource:
url: ${DATABASE_URL:jdbc:postgresql://localhost:5432/app}
username: ${DB_USER:app}
password: ${DB_PASSWORD:dev}
jpa:
hibernate:
ddl-auto: validate # Hibernate only CHECKS the schema; Flyway is the one who changes it
flyway:
enabled: true
locations: classpath:db/migration # the default, written out for clarity
# baseline-on-migrate: true # only when adopting Flyway on an existing database
3. A primeira migration e as seguintes3. The first migration and the next ones
O nome do arquivo é a configuração: V + versão + dois underscores + descrição + .sql.The file name is the configuration: V + version + two underscores + description + .sql.
src/main/resources/db/migration/
├── V1__create_users.sql
├── V2__create_orders.sql
├── V3__add_users_phone.sql
└── R__orders_summary_view.sql # repeatable: re-runs whenever its content changes
-- V1__create_users.sql (the first one: only what exists today)
CREATE TABLE users (
id bigserial PRIMARY KEY,
email varchar(255) NOT NULL UNIQUE,
name varchar(120) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- V2__create_orders.sql (a delta: the orders feature)
CREATE TABLE orders (
id bigserial PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users(id),
total_cents integer NOT NULL CHECK (total_cents >= 0),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_orders_user_id ON orders(user_id);
-- V3__add_users_phone.sql (another delta: nullable, so existing rows stay valid)
ALTER TABLE users ADD COLUMN phone varchar(30);
// The entity must match the schema; with ddl-auto=validate the app refuses to start if it does not
@Entity
@Table(name = "users")
public class User {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
@Column(nullable = false, unique = true) private String email;
@Column(nullable = false) private String name;
private String phone; // added in V3
@Column(name = "created_at", insertable = false, updatable = false) private OffsetDateTime createdAt;
}
4. Rodar e inspecionar4. Run and inspect
./mvnw spring-boot:run # applies pending migrations at startup, then starts the app
# Outside the app (CI, a deploy step), with the Flyway CLI or the Docker image:
flyway -url=jdbc:postgresql://localhost:5432/app -user=app -password=dev \
-locations=filesystem:src/main/resources/db/migration info # what is applied / pending
flyway ... migrate # apply pending
flyway ... validate # do the files match the history? (checksums)
flyway ... repair # fix the history after a failed migration (never to hide an edited file)
undo (os scripts U1__... são da edição paga). Na prática isso é bom: em produção, desfazer quase sempre é uma migration nova para frente (roll forward), não um DROP às pressas.Flyway Community has no undo (the U1__... scripts are a paid feature). In practice that is fine: in production, undoing is almost always a new forward migration (roll forward), not a rushed DROP.TypeScript: PrismaTypeScript: Prisma
No Prisma, a fonte da verdade é o arquivo schema.prisma. Você altera o modelo, e o prisma migrate dev gera uma pasta com o SQL da diferença. É o modelo "declarativo": você descreve o destino, a ferramenta escreve o caminho.In Prisma, the source of truth is the schema.prisma file. You change the model, and prisma migrate dev generates a folder with the SQL for the difference. It is the "declarative" model: you describe the destination, the tool writes the route.
1. Instalar e iniciar1. Install and initialise
npm install -D prisma
npm install @prisma/client @prisma/adapter-pg dotenv
npx prisma init --datasource-provider postgresql # creates prisma/schema.prisma, prisma.config.ts and .env
echo 'DATABASE_URL="postgresql://app:dev@localhost:5432/app"' > .env
// prisma.config.ts (Prisma 7: the connection URL lives here; in Prisma 6 it was url = env(...) in the schema)
import 'dotenv/config';
import { defineConfig, env } from 'prisma/config';
export default defineConfig({
schema: 'prisma/schema.prisma',
migrations: { path: 'prisma/migrations' },
datasource: { url: env('DATABASE_URL') },
});
2. O modelo e a primeira migration2. The model and the first migration
// prisma/schema.prisma
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
}
model User {
id BigInt @id @default(autoincrement())
email String @unique @db.VarChar(255)
name String @db.VarChar(120)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
@@map("users")
}
npx prisma migrate dev --name init
# 1. diffs schema.prisma against the database (using a temporary "shadow database")
# 2. writes prisma/migrations/20261031120000_init/migration.sql
# 3. applies it and records it in _prisma_migrations
prisma/migrations/
├── migration_lock.toml # pins the provider (postgresql); commit it
├── 20261031120000_init/migration.sql # the first one: CREATE TABLE "users" ...
└── 20261104093000_create_orders/migration.sql
3. As próximas: altere o modelo, gere, leia o SQL3. The next ones: change the model, generate, read the SQL
model User {
// ...
phone String? @db.VarChar(30) // new, optional
orders Order[]
}
model Order {
id BigInt @id @default(autoincrement())
userId BigInt @map("user_id")
user User @relation(fields: [userId], references: [id])
totalCents Int @map("total_cents")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
@@index([userId])
@@map("orders")
}
npx prisma migrate dev --name create_orders --create-only # generate the SQL but do NOT apply yet
# open prisma/migrations/<timestamp>_create_orders/migration.sql, review (or edit) it
npx prisma migrate dev # now apply it
schema.prisma gera DROP COLUMN + ADD COLUMN: os dados somem. Use --create-only, troque o SQL por ALTER TABLE ... RENAME COLUMN e só então aplique. O migrate dev também pode pedir para resetar o banco quando detecta drift: tudo bem no seu notebook, nunca em produção.Renaming a field in schema.prisma generates DROP COLUMN + ADD COLUMN: the data is gone. Use --create-only, replace the SQL with ALTER TABLE ... RENAME COLUMN and only then apply. migrate dev may also ask to reset the database when it detects drift: fine on your laptop, never in production.4. dev × deploy4. dev × deploy
| ComandoCommand | OndeWhere | O que fazWhat it does |
|---|---|---|
prisma migrate dev | só no seu notebookyour laptop only | gera migrations, aplica, pode resetar o bancogenerates migrations, applies, may reset the database |
prisma migrate deploy | CI, staging, produçãoCI, staging, production | só aplica as pendentes; nunca gera, nunca resetaonly applies pending ones; never generates, never resets |
prisma migrate status | qualquer lugaranywhere | mostra pendentes e divergênciasshows pending ones and divergences |
prisma migrate reset | só devdev only | apaga tudo e reaplica do zero (e roda o seed)drops everything and re-applies from scratch (and runs the seed) |
prisma db push | protótipoprototype | empurra o modelo direto, sem criar migration: não use em projeto com migrationspushes the model directly, without a migration: do not use in a project with migrations |
E TypeORM ou Sequelize?What about TypeORM or Sequelize?
Mesmo conceito, outros nomes. No TypeORM: synchronize: false, typeorm migration:generate src/migrations/AddPhone -d src/data-source.ts gera uma classe com up()/down() a partir das entidades, e migration:run aplica (histórico na tabela migrations). No Sequelize, o sequelize-cli cria arquivos com up/down escritos à mão e guarda o histórico em SequelizeMeta.Same concept, different names. In TypeORM: synchronize: false, typeorm migration:generate src/migrations/AddPhone -d src/data-source.ts generates a class with up()/down() from the entities, and migration:run applies it (history in the migrations table). In Sequelize, sequelize-cli creates files with hand-written up/down and keeps the history in SequelizeMeta.
Python + FastAPI: AlembicPython + FastAPI: Alembic
O FastAPI não tem ORM próprio; o par mais comum é SQLAlchemy (o ORM) + Alembic (as migrations, do mesmo autor). O Alembic gera migrations em Python comparando seus modelos com o banco (--autogenerate), e você revisa.FastAPI has no ORM of its own; the most common pair is SQLAlchemy (the ORM) + Alembic (the migrations, by the same author). Alembic generates Python migrations by diffing your models against the database (--autogenerate), and you review them.
1. Instalar e iniciar1. Install and initialise
uv add fastapi sqlalchemy alembic "psycopg[binary]" pydantic-settings
uv run alembic init migrations # creates alembic.ini and migrations/ (env.py, versions/)
# app/db.py
from datetime import datetime
from sqlalchemy import BigInteger, String, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True)
name: Mapped[str] = mapped_column(String(120))
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
# migrations/env.py (the two lines that matter)
from app.db import Base
from app.settings import settings # pydantic-settings, reads DATABASE_URL from env/.env
config.set_main_option("sqlalchemy.url", settings.database_url) # e.g. postgresql+psycopg://app:dev@localhost/app
target_metadata = Base.metadata # what autogenerate compares against
2. A primeira revisão2. The first revision
uv run alembic revision --autogenerate -m "create users"
# writes migrations/versions/3f1a9c2b7d10_create_users.py
uv run alembic upgrade head # apply everything up to the latest revision
# migrations/versions/3f1a9c2b7d10_create_users.py
from alembic import op
import sqlalchemy as sa
revision = "3f1a9c2b7d10"
down_revision = None # None = this is the first one (the root of the chain)
def upgrade() -> None:
op.create_table(
"users",
sa.Column("id", sa.BigInteger(), primary_key=True),
sa.Column("email", sa.String(255), nullable=False, unique=True),
sa.Column("name", sa.String(120), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
)
def downgrade() -> None:
op.drop_table("users")
3. As próximas: a corrente de revisões3. The next ones: the chain of revisions
Cada revisão nova aponta para a anterior com down_revision. É isso, e não o nome do arquivo, que define a ordem.Each new revision points to the previous one with down_revision. That, not the file name, defines the order.
# add `phone: Mapped[str | None] = mapped_column(String(30))` to User, then:
uv run alembic revision --autogenerate -m "add users phone"
# revision = "8b2e44c0a5f1"
# down_revision = "3f1a9c2b7d10" <- the chain
# def upgrade(): op.add_column("users", sa.Column("phone", sa.String(30), nullable=True))
uv run alembic upgrade head
uv run alembic current # which revision this database is at
uv run alembic history # the whole chain
uv run alembic downgrade -1 # undo the last one (dev); in production prefer roll forward
uv run alembic check # fails if the models have changes with no migration (great in CI)
Base.metadata.create_all() do startup da aplicação: quem cria tabela agora é o Alembic.Autogenerate does not see everything: renames become "drop + create", and type changes, unnamed constraints and some defaults may slip by. Always open the generated file. And remove Base.metadata.create_all() from the application startup: Alembic creates the tables now.Conflitos em equipeConflicts in a team
O conflito clássico: duas pessoas criam migrations ao mesmo tempo, em branches diferentes, a partir do mesmo ponto. Cada branch funciona sozinha; o problema aparece no merge.The classic conflict: two people create migrations at the same time, on different branches, from the same starting point. Each branch works alone; the problem shows up at merge time.
Como cada ferramenta reage e como resolverHow each tool reacts and how to fix it
| FerramentaTool | SintomaSymptom | ResoluçãoFix |
|---|---|---|
| Flyway | "Found more than one migration with version 5": o app não sobe. Ou, com números diferentes, uma versão menor chega depois de uma maior já aplicada e o validate acusa migration fora de ordem."Found more than one migration with version 5": the app does not start. Or, with different numbers, a lower version arrives after a higher one was applied and validate flags an out-of-order migration. | Quem fizer merge por último renumera a sua (V5 → V6) antes de ela chegar a qualquer ambiente compartilhado. Para reduzir colisões, use versões por timestamp: V20261031_1412__add_phone.sql.Whoever merges last renumbers theirs (V5 → V6) before it reaches any shared environment. To reduce collisions, use timestamp versions: V20261031_1412__add_phone.sql. |
| Prisma | Os nomes não colidem (têm timestamp), mas uma migration pode ter sido gerada contra um esquema que não incluía a do colega; o migrate dev detecta drift.Names do not collide (they have timestamps), but a migration may have been generated against a schema that lacked the colleague's; migrate dev detects drift. | Se a sua ainda não foi para ambiente compartilhado: apague a pasta dela, faça pull/rebase, rode migrate dev de novo para gerar uma migration em cima do esquema atualizado.If yours has not reached a shared environment: delete its folder, pull/rebase, run migrate dev again to generate a migration on top of the updated schema. |
| Alembic | "Multiple head revisions are present": as duas revisões têm o mesmo down_revision."Multiple head revisions are present": both revisions have the same down_revision. | alembic merge heads -m "merge" cria uma revisão que junta as duas; ou, se a sua não saiu da branch, edite o down_revision dela para apontar para a do colega.alembic merge heads -m "merge" creates a revision that joins both; or, if yours never left the branch, edit its down_revision to point at the colleague's. |
Regras que evitam a maior parte dos conflitosRules that avoid most conflicts
- Nunca edite uma migration que já saiu da sua máquina. Corrija com outra migration.Never edit a migration that has left your machine. Fix it with another migration.
- Uma mudança de esquema por migration, pequena e com nome descritivo. Migrations gigantes conflitam mais e são mais difíceis de revisar.One schema change per migration, small and descriptively named. Huge migrations conflict more and are harder to review.
- Rebase antes do merge e rode as migrations de novo localmente (de um banco limpo) para ver a sequência final.Rebase before merging and run the migrations again locally (from a clean database) to see the final sequence.
- O CI aplica todas as migrations num banco vazio a cada PR (e roda
flyway validate,prisma migrate statusoualembic check). Se a sequência não monta do zero, o PR não entra.CI applies every migration to an empty database on each PR (and runsflyway validate,prisma migrate statusoralembic check). If the sequence does not build from scratch, the PR does not merge. - Ninguém altera o banco compartilhado na mão. Um
ALTERmanual em staging é drift garantido no próximo deploy.Nobody changes the shared database by hand. A manualALTERon staging is guaranteed drift on the next deploy.
# .github/workflows/migrations.yml (the core of the check)
services:
postgres:
image: postgres:17
env: { POSTGRES_DB: app, POSTGRES_USER: app, POSTGRES_PASSWORD: dev }
ports: ["5432:5432"]
steps:
- uses: actions/checkout@v4
# pick the line for your stack:
- run: ./mvnw -q flyway:migrate flyway:validate -Dflyway.url=jdbc:postgresql://localhost:5432/app -Dflyway.user=app -Dflyway.password=dev
- run: npx prisma migrate deploy && npx prisma migrate status
- run: uv run alembic upgrade head && uv run alembic check
Em produçãoIn production
Quando rodarWhen to run
Duas opções comuns: (a) a aplicação roda as migrations no startup (padrão do Spring Boot com Flyway), ou (b) um passo separado do deploy roda migrate antes de subir a versão nova (um job no pipeline, um init container no Kubernetes, uma task one-off no ECS). Com várias réplicas, (b) é mais previsível: uma execução só, logs separados, e se falhar o deploy para antes de trocar o código.Two common options: (a) the application runs the migrations at startup (the Spring Boot default with Flyway), or (b) a separate deploy step runs migrate before rolling out the new version (a pipeline job, a Kubernetes init container, a one-off ECS task). With several replicas, (b) is more predictable: a single run, separate logs, and if it fails the deploy stops before swapping the code.
Mudanças sem downtime: expandir e contrairZero-downtime changes: expand and contract
Durante um deploy, a versão antiga e a nova do código rodam ao mesmo tempo contra o mesmo banco. Então toda migration precisa ser compatível com as duas. Renomear name para full_name num passo só quebra a versão antiga. O jeito seguro é dividir em etapas:During a deploy, the old and new versions of the code run at the same time against the same database. So every migration must be compatible with both. Renaming name to full_name in one step breaks the old version. The safe way is to split it into stages:
| PontoPoint | PráticaPractice |
|---|---|
| Coluna novaNew column | crie como NULL (ou com DEFAULT constante); NOT NULL só depois de preencher as linhas existentescreate it as NULL (or with a constant DEFAULT); NOT NULL only after filling the existing rows |
| Índice em tabela grandeIndex on a big table | CREATE INDEX CONCURRENTLY não trava escrita, mas não roda dentro de transação: no Flyway, configure executeInTransaction=false para esse script; no Alembic, use op.get_context().autocommit_block()CREATE INDEX CONCURRENTLY does not block writes, but cannot run inside a transaction: in Flyway, set executeInTransaction=false for that script; in Alembic, use op.get_context().autocommit_block() |
| Locks longosLong locks | defina SET lock_timeout = '5s' no início: melhor a migration falhar do que travar a aplicação inteira esperando um lockset SET lock_timeout = '5s' at the start: better for the migration to fail than to freeze the whole application waiting on a lock |
| TransaçõesTransactions | no Postgres, DDL é transacional: uma migration que falha volta inteira. No MySQL não: um erro no meio deixa a migration pela metade, e o Flyway exige repair depois de você limpar à mãoin Postgres, DDL is transactional: a failing migration rolls back entirely. Not in MySQL: an error halfway leaves the migration half-applied, and Flyway requires repair after you clean up by hand |
| Dados em massaBulk data | backfill de milhões de linhas em lotes, num job à parte, não num único UPDATE dentro da migrationbackfill millions of rows in batches, in a separate job, not in a single UPDATE inside the migration |
| BackupBackup | snapshot ou PITR ligado antes de migrations destrutivas (DROP, mudança de tipo)a snapshot or PITR enabled before destructive migrations (DROP, type changes) |
| DesfazerUndo | prefira roll forward (migration nova). down/downgrade ajudam em dev, mas em produção um DROP de volta pode apagar dados escritos depoisprefer roll forward (a new migration). down/downgrade help in dev, but in production a reverse DROP can erase data written afterwards |
| PermissõesPermissions | o deploy usa o usuário de migration; a aplicação roda com um usuário sem DDLthe deploy uses the migration user; the application runs with a user without DDL |
flyway migrate, prisma migrate deploy, alembic upgrade head. Nunca prisma migrate dev, migrate reset, db push, flyway clean ou ddl-auto=update contra um banco com dados reais. No Spring Boot, mantenha spring.flyway.clean-disabled=true (o padrão).The command that runs in production is always the "apply only" one: flyway migrate, prisma migrate deploy, alembic upgrade head. Never prisma migrate dev, migrate reset, db push, flyway clean or ddl-auto=update against a database with real data. In Spring Boot, keep spring.flyway.clean-disabled=true (the default).Exercício: Flyway em 10 minutosExercise: Flyway in 10 minutes
Pré-requisito: só Docker. Você vai criar um banco vazio, aplicar duas migrations, quebrar o checksum de propósito e ver a ferramenta recusar. Os conceitos valem igual para Prisma e Alembic.Prerequisite: just Docker. You will create an empty database, apply two migrations, break the checksum on purpose and watch the tool refuse. The concepts apply the same way to Prisma and Alembic.
mkdir -p mig-lab/sql && cd mig-lab
docker network create mig
docker run -d --name pg-mig --network mig -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=app postgres:17
# a helper so each command stays short
fw() { docker run --rm --network mig -v "$PWD/sql:/flyway/sql" flyway/flyway \
-url=jdbc:postgresql://pg-mig:5432/app -user=postgres -password=dev "$@"; }
cat > sql/V1__create_users.sql <<'SQL'
CREATE TABLE users (id bigserial PRIMARY KEY, email varchar(255) NOT NULL UNIQUE);
SQL
fw info # V1: Pending
fw migrate # creates flyway_schema_history and applies V1
fw info # V1: Success
Agora um delta, e depois a infração:Now a delta, and then the violation:
echo "ALTER TABLE users ADD COLUMN phone varchar(30);" > sql/V2__add_users_phone.sql
fw migrate # applies only V2
# Edit an already-applied migration:
echo "-- just a comment" >> sql/V1__create_users.sql
fw validate # ERROR: Migration checksum mismatch for migration version 1
docker exec pg-mig psql -U postgres -d app -c \
"SELECT version, description, checksum, success FROM flyway_schema_history;"
Resultado esperado: duas linhas no histórico e o validate falhando por checksum. Desfaça a edição (git checkout num projeto real) e o validate volta a passar. Esse erro é o que protege produção de alguém "consertar" uma migration antiga.Expected result: two rows in the history and validate failing on the checksum. Undo the edit (git checkout in a real project) and validate passes again. That error is what protects production from someone "fixing" an old migration.
docker rm -f pg-mig && docker network rm mig # cleanup
Cola e revisãoCheat sheet and review
ColaCheat sheet
| Quero…I want… | Flyway | Prisma | Alembic |
|---|---|---|---|
| criar uma migrationcreate a migration | novo arquivo V<n>__nome.sqlnew file V<n>__name.sql | migrate dev --name x | revision --autogenerate -m x |
| aplicar pendentesapply pending | migrate | migrate deploy | upgrade head |
| ver o estadosee the state | info | migrate status | current / history |
| banco existente (baseline)existing database (baseline) | baseline | migrate resolve --applied | stamp |
| checar no CIcheck in CI | validate | migrate status | check |
| resolver conflitoresolve a conflict | renumerar a suarenumber yours | apagar a sua e regenerardelete yours and regenerate | merge heads |
| desfazerundo | migration novanew migration | migration novanew migration | downgrade -1 (dev)(dev) |
Perguntas de revisãoReview questions
- O que é drift e por que migrations o evitam?What is drift and why do migrations prevent it?
- O que a primeira migration deve conter num projeto novo? E as seguintes?What should the first migration contain in a new project? And the next ones?
- O que precisa existir no banco antes da primeira migration rodar?What must exist in the database before the first migration runs?
- Como adotar migrations num banco que já está em produção?How do you adopt migrations on a database already in production?
- Por que editar uma migration já aplicada é um erro, e como o Flyway e o Prisma detectam isso?Why is editing an applied migration a mistake, and how do Flyway and Prisma detect it?
- Duas branches criaram migrations ao mesmo tempo. O que acontece em cada ferramenta e como resolver?Two branches created migrations at the same time. What happens in each tool and how do you fix it?
- Por que renomear uma coluna exige expandir e contrair num sistema com deploy sem downtime?Why does renaming a column require expand and contract in a system with zero-downtime deploys?