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.

repositório (pasta de migrations)repository (migrations folder) V1__create_users.sql V2__create_orders.sql V3__add_phone.sql banco: tabela de históricodatabase: history table 1 · create_users · ✓ 2 · create_orders · ✓ 3 · (pendente)3 · (pending) já aplicadaalready applied migrate aplicamigrate applies A ferramenta compara a pasta com o histórico e roda só o que falta, em ordem.The tool compares the folder with the history and runs only what is missing, in order.
Os arquivos dizem o que deve existir; a tabela de histórico diz o que já existe. A diferença são as migrations pendentes.The files say what should exist; the history table says what already exists. The difference is the pending migrations.

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 migrationsCom migrationsWith migrations
SQL solto em chat, wiki ou na memória de alguémSQL scattered in chat, a wiki or someone's memorycada 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 columntodo 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 erroro 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 ata 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 dumpbanco 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 the ALTER 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.prisma ou 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 (the schema.prisma or 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.
Nas duas filosofias o artefato que vai para produção é o mesmo: um arquivo de migration imutável, com SQL concreto. O modelo declarativo é só uma forma mais confortável de produzir esse arquivo.In both philosophies the artifact that reaches production is the same: an immutable migration file with concrete SQL. The declarative model is just a more comfortable way of producing that file.

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:

1. trava1. lock um por vezone at a time 2. lê arquivos2. read files e ordenaand sort them 3. lê histórico3. read history valida checksumscheck checksums 4. aplica4. apply as pendentespending ones 5. registra5. record linha no históricorow in history Se um arquivo já aplicado mudou (checksum diferente), a ferramenta para no passo 3.If an already-applied file changed (different checksum), the tool stops at step 3. Os passos 4 e 5 se repetem para cada pendente, uma de cada vez e em ordem.Steps 4 and 5 repeat for each pending migration, one at a time and in order.
O mesmo ciclo em Flyway, Prisma e Alembic: travar, comparar, aplicar o que falta e registrar.The same cycle in Flyway, Prisma and Alembic: lock, compare, apply what is missing and record it.
FlywayPrismaAlembic
Tabela de históricoHistory tableflyway_schema_history_prisma_migrationsalembic_version
O que guardaWhat it storesuma linha por migration: versão, descrição, checksum, data, sucessoone row per migration: version, description, checksum, date, successuma linha por migration: nome, checksum, início, fim, logsone row per migration: name, checksum, start, finish, logssó uma linha: o id da revisão atual (a "head")just one row: the id of the current revision (the "head")
Como ordenaHow it orderspelo 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 namepor 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 filesim (checksum)yes (checksum)sim (checksum)yes (checksum)não: confia em vocêno: it trusts you
Trava contra execuções simultâneasLock against simultaneous runssimyessim (advisory lock)yes (advisory lock)não: rode uma vez só, num passo do deployno: run it once, in a deploy step
A regra de ouro de qualquer ferramenta: migration aplicada em qualquer ambiente compartilhado é imutável. Errou? Escreva uma migration nova que corrige. Editar a antiga não muda nada nos bancos que já a rodaram, e o checksum vai travar o próximo deploy.The golden rule of every tool: a migration applied to any shared environment is immutable. Made a mistake? Write a new migration that fixes it. Editing the old one changes nothing in databases that already ran it, and the checksum will block the next deploy.

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; o prisma migrate dev cria 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 dev does 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 only SELECT/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 pgcrypto ou postgis podem 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 as pgcrypto or postgis can 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.

qual o estadowhat state is do banco?the database in? vazio (projeto novo)empty (new project) já tem tabelas,has tables, sem históricono history já tem históricoalready has history V1 com as tabelas de hoje,V1 with today's tables, depois um delta por tarefathen one delta per task baseline: V1 = dump do esquema,baseline: V1 = schema dump, marcado como aplicadomarked as applied roda só as pendentesruns only the pending ones
Banco vazio começa pelo V1; banco que já existe sem migrations precisa de um baseline; banco com histórico só recebe o que falta.An empty database starts at V1; an existing database without migrations needs a baseline; a database with history only gets what is missing.

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:

  1. Gere o SQL do esquema atual (pg_dump --schema-only ou o diff da ferramenta) e salve como a primeira migration.Generate the SQL of the current schema (pg_dump --schema-only or the tool's diff) and save it as the first migration.
  2. 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.
  3. 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.
FerramentaToolMarcar o baseline como aplicadoMark the baseline as applied
Flywayflyway baseline ouor spring.flyway.baseline-on-migrate=true (versão do baseline: baseline-version)(baseline version: baseline-version)
Prismanpx prisma migrate resolve --applied 0_init
Alembicalembic stamp <revision> (grava a revisão sem rodar o upgrade)(records the revision without running upgrade)
Desligue a geração automática de esquema assim que adotar migrations: 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)
O Flyway Community não tem 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
Renomear um campo no 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

ComandoCommandOndeWhereO que fazWhat it does
prisma migrate devsó no seu notebookyour laptop onlygera migrations, aplica, pode resetar o bancogenerates migrations, applies, may reset the database
prisma migrate deployCI, staging, produçãoCI, staging, productionsó aplica as pendentes; nunca gera, nunca resetaonly applies pending ones; never generates, never resets
prisma migrate statusqualquer lugaranywheremostra pendentes e divergênciasshows pending ones and divergences
prisma migrate resetsó devdev onlyapaga tudo e reaplica do zero (e roda o seed)drops everything and re-applies from scratch (and runs the seed)
prisma db pushprotótipoprototypeempurra 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)
O autogenerate não enxerga tudo: renomeações viram "apagar + criar", e mudanças de tipo, constraints sem nome e alguns defaults podem passar batido. Sempre abra o arquivo gerado. E tire o 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.

main V3 V4 V5__add_phone.sql branch da AnaAna's branch V5__add_index.sql branch do BrunoBruno's branch merge: dois V5 ✗merge: two V5s ✗ (Alembic: duas heads)(Alembic: two heads)
Cada branch é válida sozinha. Juntas, duas migrations disputam o mesmo lugar na sequência.Each branch is valid alone. Together, two migrations compete for the same place in the sequence.

Como cada ferramenta reage e como resolverHow each tool reacts and how to fix it

FerramentaToolSintomaSymptomResoluçã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.
PrismaOs 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 status ou alembic 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 runs flyway validate, prisma migrate status or alembic check). If the sequence does not build from scratch, the PR does not merge.
  • Ninguém altera o banco compartilhado na mão. Um ALTER manual em staging é drift garantido no próximo deploy.Nobody changes the shared database by hand. A manual ALTER on 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:

1. expandir1. expand ADD COLUMN full_name app escreve nas duasapp writes to both 2. migrar dados2. migrate data UPDATE ... (em lotes) app passa a ler a novaapp reads the new one 3. contrair3. contract DROP COLUMN name nenhum código usa maisno code uses it anymore Cada etapa é uma migration e um deploy separados; entre elas, as duas versões do código convivem.Each stage is a separate migration and deploy; between them, both versions of the code coexist.
Uma mudança incompatível vira três mudanças compatíveis, cada uma segura de aplicar com o código antigo ainda no ar.One incompatible change becomes three compatible ones, each safe to apply with the old code still running.
PontoPointPráticaPractice
Coluna novaNew columncrie 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 tableCREATE 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 locksdefina 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çõesTransactionsno 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 databackfill 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
BackupBackupsnapshot ou PITR ligado antes de migrations destrutivas (DROP, mudança de tipo)a snapshot or PITR enabled before destructive migrations (DROP, type changes)
DesfazerUndoprefira 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õesPermissionso 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
O comando que roda em produção é sempre o "só aplica": 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…FlywayPrismaAlembic
criar uma migrationcreate a migrationnovo arquivo V<n>__nome.sqlnew file V<n>__name.sqlmigrate dev --name xrevision --autogenerate -m x
aplicar pendentesapply pendingmigratemigrate deployupgrade head
ver o estadosee the stateinfomigrate statuscurrent / history
banco existente (baseline)existing database (baseline)baselinemigrate resolve --appliedstamp
checar no CIcheck in CIvalidatemigrate statuscheck
resolver conflitoresolve a conflictrenumerar a suarenumber yoursapagar a sua e regenerardelete yours and regeneratemerge heads
desfazerundomigration novanew migrationmigration novanew migrationdowngrade -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?