Evolução de Schema no Iceberg: Drop-Então-Add Não é um Rename
O Apache Iceberg rastreia cada coluna por um id numérico único, não pelo nome. Da minha própria investigação na especificação e de um pequeno programa em Kotlin contra um catálogo local, a armadilha que mais me pegou foi esta: um drop seguido de um add com o mesmo nome de coluna não é um rename, e tratá-lo como se fosse silenciosamente deixa seus dados históricos órfãos.
Quando o Apache Iceberg passou para Adopt no Thoughtworks Technology Radar de abril de 2026, a frase que se repete sobre ele é que a evolução de schema é "segura" e "in place". Lendo a especificação de ponta a ponta, isso é verdade num sentido preciso e perigosamente enganoso em outro. O Iceberg rastreia cada coluna por um id numérico, e esse id — não o nome da coluna — é o contrato entre os metadados e os arquivos de dados. Depois que assimilei isso, toda uma classe de operações de "rename" deixou de parecer rename.
Este post é o resultado de cavar a fundo na documentação de evolução do Iceberg e escrever um pequeno programa em Kotlin contra um catálogo Hadoop local para ver quais mudanças de schema o catálogo aceita silenciosamente, quais ele rejeita, e quais ele aceita mas quebram um leitor downstream. A única conclusão que quero deixar sobre a mesa: um DROP COLUMN x seguido de um ADD COLUMN x nunca é um rename, mesmo quando a nova coluna tem o mesmo nome e tipo da antiga. Tratá-lo como tal é a armadilha mais comum que encontrei.
Por que o id da coluna importa mais que o nome da coluna
O Iceberg atribui um id inteiro único a cada campo no momento da criação e armazena esse id tanto nos metadados da tabela quanto nos metadados dos arquivos Parquet (ou ORC, ou Avro) subjacentes. Os leitores casam valores com o schema por id, não por posição ordinal e não por nome. A documentação oficial de evolução nomeia isso diretamente: "Iceberg uses unique IDs to track each column in a table." Essa única decisão de design é toda a história da segurança.
Duas consequências saem desse design e vale a pena enunciá-las em linguagem clara.
Um rename é uma operação apenas de metadados: o id permanece, o nome muda, todo arquivo de dados existente continua válido, e qualquer leitor com uma query usando o novo nome lê os mesmos bytes físicos. Renames no Iceberg custam milissegundos porque o manifest nunca precisa olhar para os arquivos de dados.
Um drop também é apenas de metadados, mas na direção oposta: o id é aposentado do schema atual, e qualquer coluna futura chamada x ganhará um id novo. Os arquivos de dados antigos ainda carregam os bytes do x antigo, mas o schema atual não os mapeia mais, então eles ficam invisíveis para os engines de query lendo no snapshot atual.
Junte os dois e você tem a armadilha.
A armadilha do drop-então-add
Imagine uma tabela events com uma coluna payload do tipo string, escrita por seis meses. O dono do pipeline decide que a coluna deveria ser binary em vez de string. Não há caminho de alargamento de string para binary nas regras de promoção de tipos — o Iceberg permite int → long, float → double, aumentos de precisão de decimal e algumas outras, mas nada que cruze string/binary. Então o dono do pipeline faz a coisa óbvia num console SQL:
ALTER TABLE events DROP COLUMN payload;
ALTER TABLE events ADD COLUMN payload binary;O DDL tem sucesso. O catálogo commita um novo snapshot. Nada reescreve os dados subjacentes. A partir daquele momento:
- Seis meses de valores históricos de
payloadexistem em arquivos de dados antigos sob o id de coluna original. - O schema atual tem uma coluna
payloadsob um id totalmente novo. - Todo leitor que tocar em snapshots após a mudança verá
NULLparapayloadem todo arquivo antigo, porque o novo id não está presente nesses arquivos e o Iceberg preenche a coluna ausente com o default configurado — que énull, a menos queINITIAL_DEFAULTtenha sido definido na nova coluna. - Queries que atingem snapshots de time-travel anteriores ao drop ainda enxergam o
payloadantigo, porque cada snapshot aponta para a versão de schema que era a atual naquele commit.
Um leitor que rodou SELECT count(*) FROM events WHERE payload IS NOT NULL retornou um número que despencou de repente. Os dados não foram perdidos. O ponteiro para eles foi. Esse é exatamente o modo de falha que o design do column-id foi feito para evitar — e ele de fato evita o pior cenário, que seria ler silenciosamente os bytes binários antigos através da nova coluna. Mas "sem miscast silencioso" não é o mesmo que "sem regressão silenciosa".
O que de fato verifiquei, em código
Escrevi um único arquivo Kotlin contra o iceberg-core 1.6.x e um catálogo Hadoop local. Rodei o mesmo programa contra o iceberg-core 1.10.1 em maio de 2026 só para confirmar: o comportamento do column-id descrito abaixo não mudou, e o preview da v3 do spec que saiu em março de 2026 mantém a mesma semântica de ids. O programa cria uma tabela, dropa uma coluna, adiciona-a de volta com o mesmo nome e imprime o schema após cada passo. A coisa toda cabe em 80 linhas e roda numa JVM com os jars do Iceberg no classpath.
import org.apache.hadoop.conf.Configuration
import org.apache.iceberg.PartitionSpec
import org.apache.iceberg.Schema
import org.apache.iceberg.catalog.TableIdentifier
import org.apache.iceberg.hadoop.HadoopCatalog
import org.apache.iceberg.types.Types
import java.nio.file.Files
fun main() {
val warehouse = Files.createTempDirectory("iceberg-warehouse").toString()
val catalog = HadoopCatalog(Configuration(), warehouse)
val id = TableIdentifier.of("demo", "events")
val initial = Schema(
Types.NestedField.required(1, "event_id", Types.LongType.get()),
Types.NestedField.optional(2, "payload", Types.StringType.get())
)
val table = catalog.createTable(id, initial, PartitionSpec.unpartitioned())
fun dump(label: String) {
println("== $label ==")
table.refresh()
table.schema().columns().forEach { f ->
println(" id=${f.fieldId()} name=${f.name()} type=${f.type()}")
}
}
dump("after create")
table.updateSchema()
.deleteColumn("payload")
.commit()
dump("after drop payload")
table.updateSchema()
.addColumn("payload", Types.BinaryType.get())
.commit()
dump("after re-add payload as binary")
catalog.close()
}Rode com kotlin -classpath "iceberg-core-1.6.0.jar:iceberg-api-1.6.0.jar:hadoop-common-3.3.6.jar:..." iceberg_demo.kts.
A saída deixa a armadilha concreta:
== after create ==
id=1 name=event_id type=long
id=2 name=payload type=string
== after drop payload ==
id=1 name=event_id type=long
== after re-add payload as binary ==
id=1 name=event_id type=long
id=3 name=payload type=binary
O novo payload é id 3, não id 2. Qualquer arquivo de dados escrito antes do drop carrega payload sob id 2 e agora está órfão do schema vivo. Um renameColumn("payload", "payload_v2") seguido de addColumn("payload", BinaryType.get()) teria produzido o mesmo id novo para a nova coluna, mas mantido o id 2 consultável sob o novo nome — um resultado diferente e deliberado.
O que é genuinamente de graça, e o que não é
A documentação de evolução do Iceberg lista as operações que o formato garante como mudanças de metadados sem efeitos colaterais: add, drop, rename, update (widen type) e reorder. As garantias por trás dessas palavras são precisas: colunas adicionadas nunca leem valores existentes de outra coluna; dropar uma coluna nunca muda nenhuma outra coluna; atualizar uma coluna nunca muda nenhuma outra coluna; reordenar nunca muda os valores associados a um nome. Cada afirmação é uma propriedade do mapeamento de column-id, não uma promessa sobre o que o código de aplicação faz com o resultado.
Três coisas ficam fora dessa rede de segurança e me morderam nos meus próprios testes.
A primeira é a promoção de tipos. O alargamento é permitido mas limitado — a especificação lista as movimentações legais e proíbe o resto. Qualquer coisa que perca precisão (long → int, double → float, narrowing de decimal) é rejeitada. Mudar uma coluna nullable para required também é uma mudança breaking porque os arquivos antigos podem carregar nulls. Leia a tabela de promoção de tipos na especificação antes de propor uma mudança; ela é mais curta do que se imagina.
A segunda é a evolução do partition spec. O Iceberg permite que o partition spec mude sem reescrever os dados antigos: as queries caem num "split planning" onde cada layout de partição histórico é planejado separadamente sob o filtro que deriva do seu spec. Isso é uma feature de verdade, mas interage mal com drops de coluna. A issue #10487 do Iceberg documenta um caso em que adicionar uma coluna com o mesmo nome de uma chave de partição previamente dropada falha em algumas versões, e a #5676 registra um NPE em tabela v2 em toda operação subsequente após dropar uma coluna de partição antiga. Trate colunas que algum dia participaram de um partition spec como uma categoria separada e mais cuidadosa.
A terceira é o blast radius downstream. Dentro do Iceberg, o rename é de graça. Fora do Iceberg, todo consumidor que hard-coda o nome antigo da coluna — uma view do Trino, um job do Flink, um modelo dbt, um notebook Python — quebra na primeira query após o rename. Um sink de CDC que lê eventos do Debezium chaveados por nome e os aplica ao Iceberg por nome vai silenciosamente descartar campos cujo case ou grafia não combinem mais. A segurança baseada em id vive no engine. O contrato baseado em nome vive em todo job e dashboard ao redor dele.
Uma rubrica curta que agora uso
Quando olho para uma mudança de schema do Iceberg proposta, faço quatro perguntas nesta ordem.
- A mudança está na lista permitida (add, drop, rename, widen, reorder)? Se não, espere uma rejeição ou planeje uma migração de nível de tabela.
- A coluna aparece no partition spec atual ou em algum histórico? Se sim, não a dropie sem reler as issues abertas para a versão em uso.
- Algum consumidor externo referencia a coluna por nome? Se sim, o rename não é de graça para eles; coordene o deploy.
- Estou tentado a dropar e readicionar uma coluna para "mudar o tipo dela"? Se sim, pare. Adicione a nova coluna sob um novo nome, faça backfill e então dropie a antiga num commit posterior.
O modelo do Iceberg é mais forte do que formatos baseados em nome ou em posição — o column id fecha os piores buracos de corrupção silenciosa. A armadilha está em aplicar essa força ao modelo mental errado e assumir que "in-place" significa "preserva o histórico". Não significa. Significa "o schema vivo pode se mover sem reescrever arquivos", que é uma garantia diferente e mais fraca.
Na dúvida, trate mudanças de schema como se trataria uma migração de banco de dados: escreva a nova coluna ao lado da antiga, faça dual-write por uma janela, troque os leitores, depois aposente. O Iceberg torna cada um desses passos barato. Ele não torna nenhum deles automático.
Quando se apoiar nas regras de evolução do Iceberg
- Adicionar colunas opcionais com um default sensato.
- Renomear colunas quando todo consumidor downstream vive no mesmo repositório e pode ser deployado no mesmo commit.
- Alargar tipos numéricos dentro da tabela de promoção documentada.
- Reordenar colunas por razões ergonômicas dentro de uma struct.
Quando ir para uma migração coluna-a-coluna
- Qualquer mudança de tipo fora da tabela de promoção, incluindo string ↔ binary e qualquer narrowing.
- Tocar numa coluna que atualmente é, ou já foi, parte de um partition spec.
- Renames que alcançam sistemas externos por nome (sinks de CDC, ferramentas de BI, warehouses estrangeiros).
- Qualquer coisa que "só" pareça um drop-and-add do mesmo nome.
Curtindo? Talvez goste disso aqui.
Nada parecido — quer tentar outro ângulo?
Posts Relacionados
Idempotência É um Protocolo, Não uma Chave
Na primeira vez em que entreguei idempotência como um header UUID e uma consulta no Redis, uma cobrança duplicada escapou uma semana depois. Estas são minhas notas sobre tratar idempotência como um protocolo de quatro partes — deduplicação, determinismo, segurança concorrente, propagação downstream — com uma implementação mínima em Kotlin mais Postgres que se mantém firme sob retry.
Log de Eventos como Fonte da Verdade Transforma Evolução de Schema em um Problema Eterno
Quando o log é a fonte da verdade, toda mudança de schema é permanente. Um passo a passo em Kotlin/Avro do rename que passou na verificação do Schema Registry e corrompeu silenciosamente todos os eventos antigos, mais os invariantes de Protobuf e Avro que agora mantenho fixados acima da minha mesa.
O Transactional Outbox Não É uma Fila
O transactional outbox é um ledger, não uma fila. Tratá-lo como fila é o que quebra o Postgres sob carga. Este post percorre os modos de falha específicos — autovacuum travando, drift do horizonte xmin, lag do replication slot, poison pills — e as regras operacionais que realmente o mantêm funcionando em produção.
