← Todos os Posts

Migrações do SwiftData: Lightweight vs Custom, e quando você não precisa de um V2

A história de migração de schema do SwiftData é uma melhoria estrutural em relação à do Core Data, com uma armadilha em que 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 limpo. 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 schema é automática. Algumas precisam de um stage lightweight declarado. Uma pequena minoria precisa de um stage custom com closures willMigrate e didMigrate.

O 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 recorte é “o que eu declaro versus o que o SwiftData trata para mim”, porque essa decisão determina se a migração é entregue limpa ou crasha no primeiro launch.

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 fromVersion-to-toVersion com 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 exige 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”.
  • Migrações lightweight tratam: adição/renomeação/exclusão de entidades, atributos, relacionamentos; mudança de tipos de relacionamento; declaração de @Attribute(originalName:) para rastrear renomeações; especificação de delete rules. A maioria das mudanças de schema se encaixa aqui.
  • Migrações custom (MigrationStage.custom(fromVersion:toVersion:willMigrate:didMigrate:)) tratam transformações de dados: dividir uma coluna em duas, computar campos derivados, mover dados entre modelos. willMigrate tem o contexto antigo; didMigrate tem o novo contexto.
  • O iOS 26 adiciona herança de classes para tipos @Model2. Schemas que adotam herança avançam para uma nova versão com um stage lightweight a partir da versão flat-model anterior.

O modelo de três peças

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

VersionedSchema

Um snapshot dos tipos de modelo em uma versão de schema específica1. O protocolo exige:

  • static var versionIdentifier: Schema.Version. Uma tripla 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 enum-com-tipos-aninhados é a convenção. Cada VersionedSchema faz namespace de suas classes de modelo para que múltiplos schemas com o mesmo nome de modelo possam coexistir no codebase 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 de app. Os parâmetros são os próprios tipos VersionedSchema (ex.: 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. Mesmos tipos de parâmetro que .lightweight para os argumentos de versão.

SchemaMigrationPlan

A lista ordenada de stages que leva o schema 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 schema atual e o plano de migração:

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

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

O que migrações lightweight tratam automaticamente

A maioria das mudanças de schema não exige 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 seu VersionedSchema é 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 dados; o SwiftData mapeia o antigo para o novo.
  • Mudar um tipo de relacionamento. Single-to-many, many-to-many, etc.
  • Especificar delete rules. @Relationship(deleteRule: .cascade) e adições semelhantes são lightweight.

Para mudanças nesta lista, o padrão correto é não declarar um novo VersionedSchema se os tipos de modelo estiverem sob outros aspectos inalterados. O SwiftData realiza a migração lightweight automaticamente contra o schema existente.

A armadilha: adicionar um campo não exige V2

O erro mais comum em migrações 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 do SchemaV1. O build fica limpo. O primeiro launch em um dispositivo com dados V1 existentes crasha 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 percebe 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 necessidade de V2.

// 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 é entregue 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 schema 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 migrações custom são necessárias

Migrações custom merecem sua complexidade em três casos:

1. Dividir um campo em vários. Um campo String que guarda "Last, First" vira dois campos, firstName e lastName. A migração precisa ler o valor antigo, fazer parse dele e escrever 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 schema, então os novos campos são acessíveis. O fullName antigo pode precisar ter sua remoção adiada até depois que os novos campos estejam preenchidos; a limpeza é um stage de follow-up V2 para V3.

2. Computar campos derivados. Um novo @Attribute que depende de dados existentes precisa ser preenchido em tempo de 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 o formato do schema muda; custom quando o formato 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 schema. O model context que a closure recebe é o contexto do schema antigo. Use isso para capturar dados, desnormalizá-los ou preparar estado auxiliar antes de o schema mudar por baixo.

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

Qualquer 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 schema mudar.

A closure recebe um ModelContext e pode fazer fetch, modificar e salvar. A closure pode lançar erros; 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. 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)
    }
}

Schemas que adotam herança avançam para uma nova versão com um stage de migração lightweight a partir da versão flat-model anterior. A transição é automática se a herança preserva as propriedades existentes; novos campos na subclasse seguem o padrão inline-default.

O padrão se encaixa em casos onde 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 vivem no pai; as específicas vivem nos filhos.

Testando migrações

Uma migração que compila não é uma migração que entrega. Três padrões de teste que vale rodar antes do release:

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

2. Versão antiga ainda dá launch. Compile a versão anterior do app, rode-a uma vez para produzir dados V1, então compile a nova versão e verifique se ela dá launch sem crashar. O teste pega a armadilha “Duplicate version checksums” e erros de declaração semelhantes.

3. Recuperação de migração que falha. O que acontece se a migração lança erro? 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 deveria deletar 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 Single Source of Truth do cluster cobre a questão relacionada do que acontece quando um store do SwiftData é substituído via sincronização entre processos. Migrações são o análogo de evolução local desse padrão.

Modos de falha comuns

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

Declarar V2 para uma mudança que o SwiftData trataria automaticamente. O crash “Duplicate version checksums”. Correção: não declare um novo schema 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 de novo a cada launch (porque a migração parece inacabada). Correção: toda closure que modifica dados precisa 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 deletada; 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 em iOS 26+

Três conclusões.

  1. Default para nenhuma escada de VersionedSchema. Adicionar propriedades com valores padrão inline, deletar 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 de formato de schema. As closures willMigrate e didMigrate são para código que opera sobre dados, não para declarar que o schema mudou. Mudanças de formato de schema fluem por stages lightweight.

  3. Teste migrações com dados V1 reais, não apenas dados de teste sintéticos. Migrações que passam em round-trips sintéticos ainda podem falhar em dados com formato de produção com edge cases (campos nullable que o schema não cobriu, datasets grandes que estouram timeout, etc.). O custo de testar é pequeno; o custo de um crash de migração no primeiro launch é real.

O cluster Apple Ecosystem completo: App Intents tipados; servidores MCP; a questão de roteamento; Foundation Models; a distinção runtime vs tooling LLM; três superfícies; o padrão single source of truth; Two MCP Servers; hooks para desenvolvimento Apple; Live Activities; o contrato de runtime do watchOS; internals do SwiftUI; o modelo mental espacial do RealityKit; disciplina de schema do SwiftData; padrões de Liquid Glass; shipping multi-plataforma; a matriz de plataformas; framework Vision; Symbol Effects; inferência Core ML; Writing Tools API; Swift Testing; Privacy Manifest; Acessibilidade como plataforma; tipografia SF Pro; padrões espaciais do visionOS; framework Speech; sobre o que me recuso a escrever. O hub está na Apple Ecosystem Series. Para contexto mais amplo de iOS-com-AI-agents, veja o guia iOS Agent Development.

FAQ

Sempre preciso de um SchemaMigrationPlan?

Não. Apps com uma única versão de schema (o release inicial, ou apps que só fizeram mudanças lightweight até agora) não precisam de um SchemaMigrationPlan. O inicializador do ModelContainer aceita os modelos do schema 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?

Lista da Apple de mudanças elegíveis para lightweight1: adicionar entidades/atributos/relacionamentos, removê-los, renomear com @Attribute(originalName:), mudar cardinalidade de relacionamento, especificar delete rules. Se a mudança se encaixa em uma dessas e a estrutura de classe do modelo está sob outros aspectos inalterada, a migração é automática e nenhuma escada de VersionedSchema é necessária. Se a mudança requer transformação de dados (computar, dividir, mover dados), é custom.

willMigrate e didMigrate podem estar ambas configuradas?

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

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

O erro se propaga para fora da inicialização do ModelContainer. O container falha em 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 deletam o store corrompido e começam do zero. O SwiftData não deleta silenciosamente os dados do usuário em caso de falha de migração; a falha é responsabilidade do app.

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

Construa um test target que cria um ModelContainer apontado para uma URL de arquivo temporário, popula-o com dados V1, então abre-o com o novo container que inclui o plano de migração. Verifique se os dados migrados batem com as 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 real com formato de produção.

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

Sim, com uma migração lightweight. Apps que adotam herança avançam para uma nova versão de schema (ex.: V4) e declaram um MigrationStage.lightweight(fromVersion: V3.self, toVersion: V4.self). As propriedades flat 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. Apple Developer Documentation: referências de protocolo 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 schema. 

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

  3. Apple Developer Documentation: MigrationStage com os casos .lightweight(fromVersion:toVersion:) e .custom(fromVersion:toVersion:willMigrate:didMigrate:)

  4. Apple Developer Documentation: MigrationStage.custom(fromVersion:toVersion:willMigrate:didMigrate:) para a assinatura do caso. A semântica de willMigrate-roda-contra-contexto-antigo e didMigrate-roda-contra-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 no iOS 26. 

Artigos relacionados

SwiftData's Real Cost Is Schema Discipline

SwiftData's API is two macros. The cost is what happens after you ship. Optional fields are the cheap migration; non-opt…

15 min de leitura

The Privacy Manifest Deep Dive: What Counts As Data Collection

Apple's privacy manifest is a structured contract, not a checkbox: four sections, five required-reason API categories, S…

14 min de leitura

The Cleanup Layer Is the Real AI Agent Market

Charlie Labs pivoted from building agents to cleaning up after them. The AI agent market is moving from generation to pr…

15 min de leitura