Versionamento de Schema com TypeORM: Migrations do Desenvolvimento ao Deploy

Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.
Neste artigo
O erro que destrói tabelas em produção
Uma migration com synchronize: true em produção apaga colunas. Não é bug do TypeORM: é comportamento documentado. O synchronize compara as entities do código com o schema atual e executa ALTER TABLE para alinhar os dois. Se você removeu uma propriedade da entity (ou renomeou), o synchronize interpreta como "drop column". Sem confirmação, sem rollback, sem aviso.
Esse é o ponto de partida deste post: migrations existem para que mudanças de schema sejam intencionais, rastreáveis e reversíveis. O TypeORM oferece tooling para isso, mas a configuração padrão empurra você na direção errada.
Configuração base do DataSource
O TypeORM 0.3+ substituiu ormconfig.json pelo DataSource. A configuração abaixo funciona para PostgreSQL e separa o comportamento por ambiente:
// src/data-source.ts
import { DataSource } from "typeorm";
import { join } from "path";
// Migrations ficam em pasta separada das entities
// para que o CLI encontre os arquivos compilados em produção
const isProduction = process.env.NODE_ENV === "production";
export const AppDataSource = new DataSource({
type: "postgres",
host: process.env.DB_HOST ?? "localhost",
port: Number(process.env.DB_PORT ?? 5432),
username: process.env.DB_USER ?? "app",
password: process.env.DB_PASS ?? "secret",
database: process.env.DB_NAME ?? "myapp",
// synchronize NUNCA true em produção
synchronize: false,
// logging de queries só em dev para diagnóstico
logging: isProduction ? ["error", "migration"] : true,
entities: [join(__dirname, "entities", "*.{ts,js}")],
migrations: [join(__dirname, "migrations", "*.{ts,js}")],
});O synchronize: false é a única configuração segura para qualquer ambiente que persista dados reais. Em desenvolvimento, o fluxo correto é gerar migrations a partir das mudanças nas entities.
Gerando migrations a partir das entities
O CLI do TypeORM compara as entities registradas no DataSource com o schema atual do banco e gera o SQL necessário. Para que isso funcione, o banco de desenvolvimento precisa estar rodando e acessível.
Primeiro, configure o script no package.json:
{
"scripts": {
"typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js",
"migration:generate": "npm run typeorm -- migration:generate -d src/data-source.ts",
"migration:run": "npm run typeorm -- migration:run -d src/data-source.ts",
"migration:revert": "npm run typeorm -- migration:revert -d src/data-source.ts",
"migration:create": "npm run typeorm -- migration:create"
}
}Com uma entity definida:
// src/entities/Product.ts
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from "typeorm";
@Entity("products")
export class Product {
@PrimaryGeneratedColumn("uuid")
id: string;
@Column({ type: "varchar", length: 255 })
name: string;
@Column({ type: "integer" })
priceInCents: number;
// Coluna nullable porque produtos existentes não terão SKU preenchido
@Column({ type: "varchar", length: 50, nullable: true })
sku: string | null;
@CreateDateColumn({ type: "timestamptz" })
createdAt: Date;
}Gere a migration:
npm run migration:generate -- src/migrations/CreateProductsO TypeORM cria um arquivo com timestamp e o SQL inferido. O resultado se parece com isto:
// src/migrations/1719432000000-CreateProducts.ts
import { MigrationInterface, QueryRunner } from "typeorm";
export class CreateProducts1719432000000 implements MigrationInterface {
name = "CreateProducts1719432000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE "products" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"name" character varying(255) NOT NULL,
"priceInCents" integer NOT NULL,
"sku" character varying(50),
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_products_id" PRIMARY KEY ("id")
)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "products"`);
}
}Revise o SQL gerado antes de rodar. O TypeORM nem sempre gera o que você espera: nomes de constraints, ordem de colunas e tipos podem precisar de ajuste manual.
migration:generate vs. migration:create
| Aspecto | migration:generate | migration:create |
|---|---|---|
| Fonte do SQL | Diff automático entre entities e schema atual | Arquivo vazio, você escreve o SQL |
| Quando usar | Mudanças estruturais refletidas nas entities (add column, create table) | Data migrations, índices parciais, triggers, seeds |
| Risco | Pode gerar DROP COLUMN se a entity mudou de nome | Nenhum SQL automático, erro humano no SQL manual |
| Requer banco rodando | Sim | Não |
Use migration:generate para mudanças de schema. Use migration:create para tudo que não se reflete em entities: popular dados, criar índices compostos com WHERE, adicionar extensões do PostgreSQL.
Migrations manuais para operações que o ORM não cobre
O TypeORM não gera índices parciais, extensões ou triggers. Para isso, crie a migration manualmente:
// src/migrations/1719433000000-AddSearchIndex.ts
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddSearchIndex1719433000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// Índice parcial: só indexa produtos com SKU preenchido,
// reduz tamanho do índice em ~40% quando metade dos produtos não tem SKU
await queryRunner.query(`
CREATE INDEX CONCURRENTLY "IDX_products_sku_partial"
ON "products" ("sku")
WHERE "sku" IS NOT NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "IDX_products_sku_partial"`);
}
}Se você trabalha com busca full-text no PostgreSQL, criar colunas tsvector e índices GIN entra nessa mesma categoria de migration manual.
O que NÃO fazer
Anti-pattern 1: synchronize em produção
// ERRADO: synchronize ativo em produção
export const AppDataSource = new DataSource({
type: "postgres",
synchronize: process.env.NODE_ENV !== "test",
// ...
});O problema: qualquer mudança em uma entity (renomear propriedade, remover campo) gera DROP COLUMN automático no próximo boot da aplicação. Sem migration registrada, sem possibilidade de rollback, sem audit trail.
// CORRETO: synchronize desligado, migrations explícitas
export const AppDataSource = new DataSource({
type: "postgres",
synchronize: false,
migrationsRun: false, // rodar via CLI, não no boot
// ...
});Anti-pattern 2: renomear coluna via entity sem migration manual
Se você renomeia priceInCents para unitPriceInCents na entity e roda migration:generate, o TypeORM gera:
// GERADO AUTOMATICAMENTE (PERIGOSO)
public async up(queryRunner: QueryRunner): Promise<void> {
// TypeORM interpreta como DROP + ADD, não como RENAME
await queryRunner.query(`ALTER TABLE "products" DROP COLUMN "priceInCents"`);
await queryRunner.query(`ALTER TABLE "products" ADD "unitPriceInCents" integer NOT NULL`);
}Todos os valores existentes na coluna priceInCents são perdidos. A correção é escrever a migration manualmente:
// CORRETO: rename preserva dados
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "products"
RENAME COLUMN "priceInCents" TO "unitPriceInCents"
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "products"
RENAME COLUMN "unitPriceInCents" TO "priceInCents"
`);
}Anti-pattern 3: migration sem down funcional
// ERRADO: down vazio
public async down(): Promise<void> {
// TODO: implement
}Um down vazio impede rollback. Se a migration seguinte falhar, você fica preso num estado inconsistente. Escreva o down no mesmo momento que o up. Se a operação é irreversível (drop de dados), documente explicitamente:
public async down(): Promise<void> {
throw new Error(
"Migration irreversível: dados da coluna legacy_code foram migrados e a coluna original foi removida"
);
}Executando migrations no pipeline de CI/CD
O fluxo seguro separa build, migration e deploy em etapas distintas. Se você usa estratégias de deploy como blue-green ou canary, a migration precisa rodar antes do deploy da nova versão, e o schema novo precisa ser compatível com a versão antiga da aplicação (backward compatibility).
Um exemplo de step no GitHub Actions:
# .github/workflows/deploy.yml (trecho relevante)
jobs:
migrate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npm ci
- run: npm run build
# Migration roda contra o banco de produção via variáveis de ambiente seguras
- run: npx typeorm migration:run -d dist/data-source.js
env:
DB_HOST: ${{ secrets.DB_HOST }}
DB_PORT: ${{ secrets.DB_PORT }}
DB_USER: ${{ secrets.DB_USER }}
DB_PASS: ${{ secrets.DB_PASS }}
DB_NAME: ${{ secrets.DB_NAME }}
NODE_ENV: production
deploy:
needs: migrate
runs-on: ubuntu-latest
steps:
- run: echo "Deploy da aplicação após migration bem-sucedida"Se o deploy roda dentro de containers Docker, a migration pode ser um entrypoint separado ou um job do orquestrador. O ponto crítico: a migration nunca deve rodar no migrationsRun: true do DataSource em produção. Isso acopla boot da aplicação com alteração de schema, e se duas instâncias subirem simultaneamente, ambas tentam rodar a mesma migration.
Tabela de controle: a tabela migrations
O TypeORM cria automaticamente uma tabela migrations (configurável via migrationsTableName) que registra quais migrations já rodaram. Cada linha contém o timestamp e o nome da classe. O migration:run executa apenas migrations não registradas nessa tabela, em ordem de timestamp.
Dois cuidados:
- Nunca delete linhas dessa tabela manualmente. Se precisar re-executar uma migration, use
migration:revertpara desfazer na ordem correta. - Se dois desenvolvedores geram migrations com timestamps próximos e ambas alteram a mesma tabela, o merge pode criar conflito de schema. Resolva gerando uma nova migration após o merge que reconcilia o estado.
Migrations em monorepos
Se o projeto usa monorepo com Turborepo, isole o DataSource e as migrations no pacote que contém a camada de dados. O CLI do TypeORM precisa resolver o path do DataSource corretamente:
{
"scripts": {
"migration:generate": "ts-node -r tsconfig-paths/register ../../node_modules/typeorm/cli.js migration:generate -d src/data-source.ts"
}
}O path relativo para node_modules muda conforme o hoisting do package manager. Com pnpm (hoisting desabilitado por padrão), o TypeORM CLI precisa estar listado como dependência direta do pacote.
Testando migrations em CI
Um teste de sanidade que vale o investimento: subir um banco limpo, rodar todas as migrations, verificar que o schema final bate com as entities. Isso pega migrations corrompidas, conflitos de merge e SQL inválido antes de chegar em produção.
// src/__tests__/migrations.integration.test.ts
import { AppDataSource } from "../data-source";
// Requer banco PostgreSQL rodando (use testcontainers ou docker-compose no CI)
beforeAll(async () => {
// Conecta com banco limpo dedicado para testes de migration
await AppDataSource.initialize();
});
afterAll(async () => {
await AppDataSource.destroy();
});
test("todas as migrations rodam sem erro e são reversíveis", async () => {
// Roda todas as migrations pendentes
const executedUp = await AppDataSource.runMigrations();
expect(executedUp.length).toBeGreaterThan(0);
// Reverte todas na ordem inversa
for (const _ of executedUp) {
await AppDataSource.undoLastMigration();
}
// Roda novamente para garantir idempotência do ciclo up/down/up
const executedAgain = await AppDataSource.runMigrations();
expect(executedAgain.length).toBe(executedUp.length);
});Esse teste complementa os testes de API e garante que a camada de persistência não quebra silenciosamente.
Backward compatibility em deploys com zero downtime
Se a aplicação roda múltiplas instâncias (o cenário comum em produção), a migration precisa ser backward compatible: o schema novo deve funcionar com o código antigo que ainda está rodando durante o deploy.
A regra prática:
- Adicionar coluna nullable ou com default: seguro. Código antigo ignora a coluna nova.
- Remover coluna: perigoso. Código antigo ainda referencia a coluna. Faça em duas etapas: primeiro deploy remove a referência no código, segundo deploy (dias depois) remove a coluna via migration.
- Renomear coluna: perigoso pelos mesmos motivos. Adicione a coluna nova, copie os dados, deploy o código que usa a coluna nova, depois remova a antiga.
Esse padrão de expand-and-contract é o mesmo usado em migrations seguras com Prisma. A ferramenta muda, o princípio não.
Quando o TypeORM não é a melhor escolha para migrations
Se o projeto usa TypeORM apenas para migrations (sem usar as entities/repositories no runtime), considere ferramentas dedicadas como node-pg-migrate ou dbmate. Elas são mais leves, não exigem decorators e funcionam com SQL puro.
Se o projeto já adota DDD com Prisma, misturar dois ORMs para ter migrations de ambos cria confusão. Escolha um e mantenha.
O TypeORM faz sentido para migrations quando você já usa o ORM no runtime e quer o benefício do migration:generate automático a partir das entities. Fora desse cenário, ferramentas SQL-first tendem a ser mais previsíveis.
FAQ
Posso usar synchronize: true em desenvolvimento local?
Pode, mas não deveria. O synchronize não gera arquivos de migration, então você perde o histórico de mudanças. Se outro dev clonar o repositório, não terá como reproduzir o schema. Use migration:generate mesmo em dev.
Como lidar com migrations em branches diferentes que alteram a mesma tabela?
Após o merge, rode migration:generate novamente. O TypeORM vai detectar o diff entre o schema atual (resultado das migrations de ambas branches) e as entities. Se houver conflito, a migration gerada resolve. Revise o SQL com atenção redobrada.
O migration:run é idempotente?
Sim, no sentido de que não re-executa migrations já registradas na tabela migrations. Se a migration falhou no meio (DDL parcial sem transação), o estado fica inconsistente. PostgreSQL executa DDL dentro de transações, então migrations que usam apenas DDL são atômicas. MySQL não tem DDL transacional: uma migration que falha no meio deixa o schema num estado intermediário.
Devo commitar migrations geradas ou gerá-las no CI? Commite. Migrations são artefatos de versionamento, assim como o código. Gerar no CI significa que o schema depende do estado do banco no momento do build, o que é imprevisível.
Como rodar migrations em ambientes serverless com Neon?
O mesmo CLI funciona. A diferença é que o connection string aponta para o endpoint do Neon. Cuidado com cold starts do banco: se o branch do Neon estiver suspenso, a primeira conexão pode demorar 2-5 segundos. Configure connectTimeoutMS no DataSource para evitar timeout no CI.
Posição editorial
Migrations são código de infraestrutura com impacto direto em dados de produção. Tratar migrations como "coisa que o ORM resolve sozinho" é aceitar que uma propriedade renomeada pode apagar uma coluna inteira sem aviso. O TypeORM oferece ferramentas boas para gerar e executar migrations, mas a responsabilidade de revisar o SQL gerado, escrever downs funcionais e garantir backward compatibility é do engenheiro. Automatize a execução, nunca a revisão.

Escrito por
Marcos Soares
Fullstack Developer · CEO da Agência Poti
Fullstack Developer e CEO da Agência Poti. Mais de 20 anos construindo arquiteturas cloud-native com React, Next.js e sistemas distribuídos. Parceiro comercial do estúdio iellou design. Fundador do Vivo de Código.
Comentários
Participe da discussão
Seja o primeiro a comentar!
Continue Aprofundando
Guias de integração relacionados
Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.


