← Todos os Posts

Migrações no SwiftData: lightweight vs. custom, e quando você não precisa de um V2

A história de migração de esquema do SwiftData é uma melhoria estrutural em relação à do Core Data, com uma armadilha na qual as equipes continuam caindo: declarar um novo VersionedSchema para mudanças que o SwiftData trataria automaticamente por meio de valores padrão inline. O resultado é um crash “Duplicate version checksums across stages detected” no dispositivo, mesmo com o código parecendo correto e compilando sem erros. O modelo de migração real do framework usa três peças (VersionedSchema, MigrationStage, SchemaMigrationPlan) e três tipos de migração (lightweight automática, lightweight declarada, custom)1. A maioria das mudanças de esquema é automática. Algumas precisam de um stage lightweight declarado. Uma pequena minoria precisa de um stage custom com closures willMigrate e didMigrate.

Este post percorre o modelo de migração à luz da documentação da Apple, nomeia os casos que cada tipo de migração trata e cobre o novo suporte a herança de classes do iOS 26. O enquadramento é “o que eu declaro versus o que o SwiftData trata por mim”, porque essa decisão determina se a migração é publicada de forma limpa ou trava no primeiro lançamento.

TL;DR

  • As migrações do SwiftData compõem três protocolos: VersionedSchema (um snapshot dos tipos de modelo em uma versão), MigrationStage (uma única transição de fromVersion para toVersion com os casos .lightweight ou .custom) e SchemaMigrationPlan (lista ordenada de stages)1.
  • Adicionar uma nova propriedade @Model com um valor padrão inline (var foo: Bool = false) não requer um novo VersionedSchema. O SwiftData trata a adição automaticamente como uma migração lightweight. Declarar um V2 para isso produz crashes “Duplicate version checksums across stages detected”.
  • As migrações lightweight tratam: adicionar/renomear/excluir entidades, atributos, relacionamentos; mudar tipos de relacionamento; declarar @Attribute(originalName:) para rastrear renomeações; especificar regras de exclusão. A maioria das mudanças de esquema se encaixa aqui.
  • As migrações custom (MigrationStage.custom(fromVersion:toVersion:willMigrate:didMigrate:)) tratam transformações de dados: dividir uma coluna em duas, calcular campos derivados, mover dados entre modelos. willMigrate tem o contexto antigo; didMigrate tem o contexto novo.
  • O iOS 26 adiciona herança de classes para tipos @Model2. Esquemas que adotam herança avançam para uma nova versão com um stage lightweight a partir da versão anterior de modelo plano.

O modelo de três peças

Uma migração do SwiftData é composta por três peças.

VersionedSchema

Um snapshot dos tipos de modelo em uma versão específica do esquema1. O protocolo requer:

  • static var versionIdentifier: Schema.Version. Um tripleto de versão semântica (Schema.Version(1, 0, 0)).
  • static var models: [any PersistentModel.Type]. O array de tipos @Model nesta versão.
enum SchemaV1: VersionedSchema {
    static let versionIdentifier = Schema.Version(1, 0, 0)
    static var models: [any PersistentModel.Type] {
        [Item.self]
    }

    @Model
    final class Item {
        var name: String
        var createdAt: Date
        init(name: String, createdAt: Date) {
            self.name = name
            self.createdAt = createdAt
        }
    }
}

O padrão de enum com tipos aninhados é a convenção. Cada VersionedSchema cria um namespace para suas classes de modelo, de modo que múltiplos esquemas com o mesmo nome de modelo possam coexistir na base de código durante uma migração.

MigrationStage

Uma única transição entre dois tipos VersionedSchema3. Dois casos:

  • .lightweight(fromVersion: any VersionedSchema.Type, toVersion: any VersionedSchema.Type). Declara uma transição que o SwiftData trata sem código do app. Os parâmetros são os próprios tipos VersionedSchema (por exemplo, SchemaV1.self), não valores brutos Schema.Version.
  • .custom(fromVersion:toVersion:willMigrate:didMigrate:). Declara uma transição com código que roda antes e/ou depois da migração de dados. Os mesmos tipos de parâmetro de .lightweight para os argumentos de versão.

SchemaMigrationPlan

A lista ordenada de stages que leva o esquema de qualquer versão anterior até a atual1.

enum AppMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [SchemaV1.self, SchemaV2.self, SchemaV3.self]
    }

    static var stages: [MigrationStage] {
        [migrateV1toV2, migrateV2toV3]
    }

    static let migrateV1toV2 = MigrationStage.lightweight(
        fromVersion: SchemaV1.self,
        toVersion: SchemaV2.self
    )

    static let migrateV2toV3 = MigrationStage.custom(
        fromVersion: SchemaV2.self,
        toVersion: SchemaV3.self,
        willMigrate: { context in
            // Pre-migration: read old data, prepare it
            try context.save()
        },
        didMigrate: { context in
            // Post-migration: backfill new fields
            let descriptor = FetchDescriptor<SchemaV3.Item>()
            let items = try context.fetch(descriptor)
            for item in items {
                item.computedField = computeFromExisting(item)
            }
            try context.save()
        }
    )
}

O ModelContainer é configurado com o esquema atual e o plano de migração:

let container = try ModelContainer(
    for: SchemaV3.Item.self,
    migrationPlan: AppMigrationPlan.self,
    configurations: ModelConfiguration(...)
)

Na criação do container, o SwiftData lê a versão de esquema atual do persistent store, percorre os stages do plano dessa versão para frente até a atual e aplica cada stage em ordem.

O que as migrações lightweight tratam automaticamente

A maioria das mudanças de esquema não requer um stage custom1:

  • Adicionar um atributo com um valor padrão. var foo: Bool = false em um @Model existente é automático.
  • Adicionar uma nova entidade (classe de modelo). Novos tipos aparecem quando o VersionedSchema deles é o atual; os dados existentes são preservados.
  • Remover um atributo ou entidade. O SwiftData descarta a coluna ou tabela.
  • Renomear um atributo ou entidade. Adicione @Attribute(originalName: "oldName") à propriedade para preservar os dados; o SwiftData mapeia o antigo para o novo.
  • Mudar um tipo de relacionamento. Um-para-muitos, muitos-para-muitos, etc.
  • Especificar regras de exclusão. @Relationship(deleteRule: .cascade) e adições semelhantes são lightweight.

Para mudanças nessa lista, o padrão certo é não declarar um novo VersionedSchema se os tipos de modelo permanecem inalterados. O SwiftData executa a migração lightweight automaticamente contra o esquema existente.

A armadilha: adicionar um campo não requer V2

O erro de migração mais comum do SwiftData: um desenvolvedor adiciona uma nova propriedade com um valor padrão inline (var foo: Bool = false) e então declara um SchemaV2 referenciando os mesmos tipos de modelo que o SchemaV1. A compilação é limpa. O primeiro lançamento em um dispositivo com dados V1 existentes trava com Duplicate version checksums across stages detected, porque tanto SchemaV1 quanto SchemaV2 resolvem para o mesmo checksum (os tipos de modelo não mudaram de uma forma que o SwiftData perceba como diferente).

O padrão correto: deixe o VersionedSchema existente em paz, adicione a nova propriedade ao modelo com um valor padrão inline e deixe a migração lightweight automática do SwiftData tratar disso. Sem MigrationPlan, sem MigrationStage, sem V2 necessário.

// V1 schema
enum SchemaV1: VersionedSchema {
    @Model
    final class Item {
        var name: String
        // BEFORE: just these two properties
        var createdAt: Date
        // AFTER: add a third with inline default
        var isFavorite: Bool = false   // Lightweight, automatic
    }
}

A mudança var isFavorite: Bool = false é publicada sem nenhuma declaração de MigrationStage. O inicializador do ModelContainer que não passa migrationPlan: funciona:

let container = try ModelContainer(
    for: SchemaV1.Item.self,
    configurations: ModelConfiguration(...)
)

O esquema V2 só é necessário quando uma mudança não pode ser lightweight (uma transformação de dados, uma divisão de modelo, uma reestruturação de herança que exige lógica custom). Nesses casos, o V2 é real e um SchemaMigrationPlan orquestra a transição.

Quando as migrações custom são necessárias

As migrações custom justificam sua complexidade em três casos:

1. Dividir um campo em vários. Um campo String que armazena "Last, First" se torna dois campos, firstName e lastName. A migração precisa ler o valor antigo, analisá-lo e gravar os novos campos.

static let migrateV1toV2 = MigrationStage.custom(
    fromVersion: SchemaV1.self,
    toVersion: SchemaV2.self,
    willMigrate: nil,
    didMigrate: { context in
        let descriptor = FetchDescriptor<SchemaV2.Person>()
        let people = try context.fetch(descriptor)
        for person in people {
            let parts = person.fullName.split(separator: ", ", maxSplits: 1)
            person.lastName = String(parts.first ?? "")
            person.firstName = String(parts.dropFirst().first ?? "")
        }
        try context.save()
    }
)

A closure didMigrate roda contra o contexto do novo esquema, então os novos campos ficam acessíveis. A remoção do antigo fullName pode precisar ser adiada até depois que os novos campos forem populados; a limpeza é um stage de acompanhamento V2-para-V3.

2. Calcular campos derivados. Um novo @Attribute que depende de dados existentes precisa ser preenchido (backfill) no momento da migração.

3. Mover dados entre modelos. Uma reorganização em que dados de Item são divididos entre Item e um novo modelo Tag exige lógica custom para atribuir tags a partir dos dados antigos.

O padrão geral: lightweight quando a forma do esquema muda; custom quando a forma dos dados muda.

willMigrate vs. didMigrate

Stages custom têm duas closures, chamadas em pontos diferentes4:

willMigrate roda antes de o SwiftData aplicar a migração de esquema. O model context que a closure recebe é o contexto do esquema antigo. Use isso para capturar dados, desnormalizá-los ou preparar estado auxiliar antes de o esquema mudar por baixo.

didMigrate roda depois da migração de esquema. O model context é o do esquema novo. Use isso para preencher novos campos, calcular dados derivados ou finalizar a migração.

Qualquer uma das closures pode ser nil se não for necessária. A maioria das migrações custom usa apenas didMigrate; willMigrate é útil quando a migração precisa ler dados antigos que não estarão acessíveis depois que o esquema mudar.

A closure recebe um ModelContext e pode buscar, modificar e salvar. A closure é throwing; os erros se propagam para fora da migração e a abortam.

iOS 26: herança de classes para @Model

O iOS 26 introduz herança de classes para modelos do SwiftData2. Os modelos agora podem ter relacionamentos pai-filho:

@Model
class Vehicle {
    var make: String
    var year: Int
    init(make: String, year: Int) {
        self.make = make
        self.year = year
    }
}

@Model
final class Car: Vehicle {
    var doorCount: Int
    init(make: String, year: Int, doorCount: Int) {
        self.doorCount = doorCount
        super.init(make: make, year: year)
    }
}

Esquemas que adotam herança avançam para uma nova versão com um stage de migração lightweight a partir da versão anterior de modelo plano. A transição é automática se a herança preservar as propriedades existentes; novos campos na subclasse seguem o padrão padrão de valor inline.

O padrão se encaixa em casos em que múltiplos tipos @Model compartilham características: um pai Vehicle com filhos Car, Truck, Motorcycle; um pai Account com filhos CheckingAccount, SavingsAccount. As propriedades compartilhadas ficam no pai; as específicas ficam nos filhos.

Testando migrações

Uma migração que compila não é uma migração que está pronta para publicar. Três padrões de teste que vale a pena rodar antes do lançamento:

1. Teste de ida e volta em uma cópia do banco de dados de produção. Puxe um banco de dados recente com formato de produção (ou gere dados V1 sintéticos por meio de testes), abra-o com o container ciente do V2 e verifique se os dados migram corretamente. O teste captura bugs de migração custom que o type-checker não consegue pegar.

2. A versão antiga ainda inicia. Compile a versão anterior do app, execute-a uma vez para produzir dados V1, depois compile a nova versão do app e verifique se ela inicia sem travar. O teste captura a armadilha “Duplicate version checksums” e erros de declaração semelhantes.

3. Recuperação de migração com falha. O que acontece se a migração lançar uma exceção? O comportamento do SwiftData depende da configuração do container; para apps de produção, um erro de migração não tratado não deve excluir silenciosamente os dados do usuário. Teste o caminho de falha explicitamente e decida o que o app faz (rollback, prompt, recuperar a partir de backup).

O post sobre Fonte Única da Verdade do cluster cobre a questão relacionada do que acontece quando um store do SwiftData é substituído por meio de sincronização entre processos. As migrações são o análogo de evolução local desse padrão.

Publicando migrações entre processos e expondo o progresso

Dois detalhes operacionais que a documentação não coloca em primeiro plano, mas que a equipe do SwiftData destacou na WWDC 20265: onde as migrações rodam quando um app tem widgets ou extensions, e como conduzir uma UI de progresso quando uma roda.

Um processo é dono da migração. Widgets e extensions não recebem os mesmos recursos de runtime que o app principal, então não podem executar uma migração com segurança. A orientação é manter o SchemaMigrationPlan1 totalmente fora dos targets de widget e extension e nunca migrar a partir deles. Escolha um processo, normalmente o app principal, como o dono do banco de dados. Se um widget abrir o container e o store em disco estiver em um esquema sem versão (mais antigo), a abertura resulta em erro. Trate esse erro como o sinal de que uma migração é necessária: exiba uma UI que peça ao usuário para abrir o app principal, deixe o app executar a migração e faça o app gravar a versão de esquema migrada em um UserDefault compartilhado. O widget lê esse valor da próxima vez e abre o container na versão para a qual o app já migrou. O padrão mantém um único gravador no comando e evita que dois processos compitam para evoluir o mesmo arquivo.

O progresso é calculado a partir da contagem de stages, não do tempo de relógio. O SwiftData não expõe nenhuma API dedicada de progresso de migração5. Para conduzir um indicador de progresso, conte o número total de stages de migração custom no plano e sobrescreva o handler didMigrate4 por stage para que cada stage reporte sua posição, “stage N de M”. O número reflete os stages concluídos, não o tempo decorrido, então a barra avança em passos discretos em vez de suavemente. A decisão de design complementar é o que o app mostra durante a migração: um spinner sozinho é lido como travamento e os usuários abandonam. Mantenha o app parcialmente utilizável onde os dados permitirem, ou no mínimo descreva o que cada stage está adicionando (os novos recursos que a migração desbloqueia), para que a espera seja lida como progresso rumo a algo, em vez de tempo morto.

Modos de falha comuns

Três padrões dos logs de falha do SwiftData:

Declarar um V2 para uma mudança que o SwiftData trataria automaticamente. O crash “Duplicate version checksums”. Correção: não declare um novo esquema para adições de propriedade com valor padrão inline; deixe o SwiftData tratá-las automaticamente.

Código de migração custom que não salva. Uma closure didMigrate que modifica entidades mas não chama context.save() produz uma migração que roda uma vez, descarta seu trabalho e roda novamente a cada lançamento (porque a migração parece inacabada). Correção: toda closure que modifica dados deve fazer try context.save() antes de retornar.

Renomear uma propriedade sem @Attribute(originalName:). O SwiftData trata a nova propriedade como nova e a antiga como excluída; os dados existentes na propriedade antiga são descartados. Correção: declare @Attribute(originalName: "oldName") var newName: ... para que o SwiftData mapeie os dados através da renomeação.

O que esse padrão significa para apps iOS 26+

Três conclusões.

  1. Por padrão, não use uma escada de VersionedSchema. Adicionar propriedades com valores padrão inline, excluir campos não usados, renomear com @Attribute(originalName:). Tudo lightweight e automático. A escada de VersionedSchema é para mudanças que o SwiftData genuinamente não consegue tratar automaticamente (transformações de dados, lógica custom, reestruturações de herança).

  2. Use MigrationStage.custom para transformações de dados, não para mudanças na forma do esquema. As closures willMigrate e didMigrate são para código que opera sobre dados, não para declarar que o esquema mudou. Mudanças na forma do esquema fluem por stages lightweight.

  3. Teste migrações com dados V1 reais, não apenas com dados de teste sintéticos. Migrações que passam em ida e volta sintéticas ainda podem falhar com dados em formato de produção que têm casos extremos (campos anuláveis que o esquema não cobriu, conjuntos de dados grandes que estouram o timeout, etc.). O custo de testar é pequeno; o custo de um crash de migração no primeiro lançamento é real.

O cluster completo do Apple Ecosystem: App Intents tipados; servidores MCP; a questão de roteamento; Foundation Models; a distinção entre LLM de runtime e de tooling; três superfícies; o padrão de fonte única da verdade; Dois servidores MCP; hooks para desenvolvimento Apple; Live Activities; o runtime do watchOS; internals do SwiftUI; o modelo mental espacial do RealityKit; disciplina de esquema no SwiftData; padrões de Liquid Glass; publicação multiplataforma; a matriz de plataformas; Vision framework; Symbol Effects; inferência com Core ML; Writing Tools API; Swift Testing; Privacy Manifest; Acessibilidade como plataforma; tipografia SF Pro; padrões espaciais do visionOS; Speech framework; sobre o que eu me recuso a escrever. O hub está na Série Apple Ecosystem. Para um contexto mais amplo de iOS com agentes de IA, veja o guia de Desenvolvimento de Agentes iOS.

FAQ

Eu sempre preciso de um SchemaMigrationPlan?

Não. Apps com uma única versão de esquema (o lançamento inicial, ou apps que só fizeram mudanças lightweight) não precisam de um SchemaMigrationPlan. O inicializador do ModelContainer aceita os models do esquema diretamente. O parâmetro migrationPlan: se torna necessário na primeira vez que um stage de migração custom é declarado (ou na primeira vez que o desenvolvedor quer declarar uma escada de versões explícita).

Como sei se minha mudança é lightweight?

A lista de mudanças elegíveis a lightweight da Apple1: adicionar entidades/atributos/relacionamentos, removê-los, renomear com @Attribute(originalName:), mudar a cardinalidade de relacionamento, especificar regras de exclusão. Se a mudança se encaixa em uma dessas e a estrutura da classe de modelo permanece inalterada, a migração é automática e nenhuma escada de VersionedSchema é necessária. Se a mudança exige transformação de dados (calcular, dividir, mover dados), ela é custom.

willMigrate e didMigrate podem ambos ser definidos?

Sim. Ambas as closures são opcionais individualmente, mas as duas podem ser fornecidas. willMigrate roda contra o contexto do esquema antigo antes de o SwiftData migrar; didMigrate roda contra o contexto do esquema novo depois. As duas cobrem preparação e finalização, respectivamente.

O que acontece se uma migração lançar um erro?

O erro se propaga para fora da inicialização do ModelContainer. O container falha ao abrir. O comportamento do app depende de como o desenvolvedor trata o erro: alguns apps exibem uma UI de recuperação, alguns tentam restaurar a partir de um backup, alguns excluem o store corrompido e começam do zero. O SwiftData não exclui silenciosamente os dados do usuário em caso de falha de migração; a falha é do app para tratar.

Como testo uma migração sem afetar os dados de produção?

Crie um target de teste que cria um ModelContainer apontado para uma URL de arquivo temporária, popula-o com dados V1 e então o abre com o novo container que inclui o plano de migração. Verifique se os dados migrados correspondem às expectativas. O padrão funciona tanto em testes unitários quanto de integração; para os resultados mais realistas, use uma cópia de um banco de dados real em formato de produção.

A herança de classes do iOS 26 funciona com esquemas existentes?

Sim, com uma migração lightweight. Apps que adotam herança avançam para uma nova versão de esquema (por exemplo, V4) e declaram um MigrationStage.lightweight(fromVersion: V3.self, toVersion: V4.self). As propriedades planas da classe pai permanecem, e as propriedades específicas da subclasse são adicionadas com valores padrão inline. A migração lightweight do SwiftData trata a mudança estrutural.

Referências


  1. Documentação para Desenvolvedores da Apple: referências dos protocolos VersionedSchema e SchemaMigrationPlan. O modelo de migração. Veja também o guia relacionado Adopting SwiftData for a Core Data app para a narrativa completa de evolução de esquema. 

  2. Apple Developer: SwiftData: Dive into inheritance and schema migration (sessão 291 da WWDC 2025). A introdução da herança de classes do SwiftData no iOS 26. 

  3. Documentação para Desenvolvedores da Apple: MigrationStage com os casos .lightweight(fromVersion:toVersion:) e .custom(fromVersion:toVersion:willMigrate:didMigrate:)

  4. Documentação para Desenvolvedores da Apple: MigrationStage.custom(fromVersion:toVersion:willMigrate:didMigrate:) para a assinatura do caso. A semântica de willMigrate-roda-contra-o-contexto-antigo e didMigrate-roda-contra-o-contexto-novo está documentada na sessão 291 da WWDC 2025 SwiftData: Dive into inheritance and schema migration, a mesma sessão referenciada para a adição de herança do iOS 26. 

  5. WWDC 2026 SwiftData Group Lab (sessão 8017). Parafraseado de uma gravação do WWDC 2026 SwiftData Group Lab transcrita localmente; a Apple não publica legendas oficiais para os labs. O gating de migração de widget e extension (um processo é dono da migração, o caminho de erro é o sinal de migração, a versão migrada é armazenada em um UserDefault) e a técnica de progresso por contagem de stages (sobrescrever o handler didMigrate por stage para reportar o stage N de M, já que não existe uma API dedicada de progresso) foram descritos pelo painel de engenharia do SwiftData. Os símbolos SchemaMigrationPlan e didMigrate de MigrationStage.custom são confirmados contra a Documentação para Desenvolvedores da Apple citada em 1 e 4; a ausência de uma API dedicada de progresso reflete o próprio enquadramento do painel durante o lab. 

Artigos relacionados

O verdadeiro custo do SwiftData é a disciplina de schema

A API do SwiftData são duas macros. O custo é o que acontece depois que você publica. Campos opcionais são a migração ba…

17 min de leitura

SwiftData no iOS 27: Observation e History

O iOS 27 dá ao SwiftData observação de mudanças de primeira classe com ResultsObserver, observação de histórico persiste…

12 min de leitura

Agora os assistentes são o público

Dados próprios do edge: os assistentes de IA solicitam minhas páginas cerca de 66x mais do que humanos as visitam, e a m…

14 min de leitura