← Alle Beitrage

SwiftData-Migrationen: Lightweight vs. Custom – und wann Sie kein V2 brauchen

Die Schema-Migrationsgeschichte von SwiftData ist eine strukturelle Verbesserung gegenüber Core Data, mit einer Falle, in die Teams immer wieder hineinfallen: ein neues VersionedSchema für Änderungen zu deklarieren, die SwiftData automatisch über Inline-Defaults handhaben würde. Das Ergebnis ist ein „Duplicate version checksums across stages detected”-Crash auf dem Gerät, obwohl der Code korrekt aussah und sauber kompilierte. Das tatsächliche Migrationsmodell des Frameworks verwendet drei Bausteine (VersionedSchema, MigrationStage, SchemaMigrationPlan) und drei Migrationstypen (automatisch lightweight, deklariert lightweight, custom)1. Die meisten Schemaänderungen sind automatisch. Manche brauchen eine deklarierte Lightweight-Stage. Eine kleine Minderheit benötigt eine Custom-Stage mit willMigrate- und didMigrate-Closures.

Der Beitrag arbeitet das Migrationsmodell anhand der Apple-Dokumentation durch, benennt die Fälle, die jeder Migrationstyp abdeckt, und behandelt die neue Klassenvererbung in iOS 26. Der Rahmen lautet „was deklariere ich versus was übernimmt SwiftData für mich”, denn diese Entscheidung bestimmt, ob die Migration sauber ausgeliefert wird oder beim ersten Start abstürzt.

TL;DR

  • SwiftData-Migrationen setzen sich aus drei Protokollen zusammen: VersionedSchema (eine Momentaufnahme der Modelltypen zu einer Version), MigrationStage (ein einzelner Übergang von fromVersion zu toVersion mit .lightweight- oder .custom-Fällen) und SchemaMigrationPlan (geordnete Liste von Stages)1.
  • Das Hinzufügen einer neuen @Model-Eigenschaft mit Inline-Default (var foo: Bool = false) erfordert kein neues VersionedSchema. SwiftData handhabt die Ergänzung automatisch als Lightweight-Migration. Ein V2 dafür zu deklarieren, erzeugt „Duplicate version checksums across stages detected”-Crashes.
  • Lightweight-Migrationen handhaben: Hinzufügen/Umbenennen/Löschen von Entitäten, Attributen, Beziehungen; Ändern von Beziehungstypen; Deklarieren von @Attribute(originalName:), um Umbenennungen nachzuhalten; Festlegen von Löschregeln. Die meisten Schemaänderungen passen hier hinein.
  • Custom-Migrationen (MigrationStage.custom(fromVersion:toVersion:willMigrate:didMigrate:)) handhaben Datentransformationen: das Aufteilen einer Spalte in zwei, das Berechnen abgeleiteter Felder, das Verschieben von Daten zwischen Modellen. willMigrate hat den alten Context; didMigrate hat den neuen Context.
  • iOS 26 fügt Klassenvererbung für @Model-Typen hinzu2. Schemata, die Vererbung übernehmen, springen auf eine neue Version mit einer Lightweight-Stage von der vorherigen flachen Modellversion.

Das Drei-Komponenten-Modell

Eine SwiftData-Migration ist aus drei Bausteinen zusammengesetzt.

VersionedSchema

Eine Momentaufnahme der Modelltypen zu einer bestimmten Schemaversion1. Das Protokoll verlangt:

  • static var versionIdentifier: Schema.Version. Ein semantisches Versions-Tripel (Schema.Version(1, 0, 0)).
  • static var models: [any PersistentModel.Type]. Das Array der @Model-Typen in dieser Version.
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
        }
    }
}

Das Muster aus Enum mit verschachtelten Typen ist die Konvention. Jedes VersionedSchema kapselt seine Modellklassen in einem eigenen Namespace, sodass mehrere Schemata mit demselben Modellnamen während einer Migration im Codebase koexistieren können.

MigrationStage

Ein einzelner Übergang zwischen zwei VersionedSchema-Typen3. Zwei Fälle:

  • .lightweight(fromVersion: any VersionedSchema.Type, toVersion: any VersionedSchema.Type). Deklarieren Sie einen Übergang, den SwiftData ohne App-Code handhabt. Die Parameter sind die VersionedSchema-Typen selbst (z. B. SchemaV1.self), nicht rohe Schema.Version-Werte.
  • .custom(fromVersion:toVersion:willMigrate:didMigrate:). Deklarieren Sie einen Übergang mit Code, der vor und/oder nach der Datenmigration läuft. Dieselben Parametertypen wie bei .lightweight für die Versionsargumente.

SchemaMigrationPlan

Die geordnete Liste der Stages, die das Schema von einer beliebigen früheren Version zur aktuellen führt1.

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()
        }
    )
}

Der ModelContainer wird mit dem aktuellen Schema und dem Migrationsplan eingerichtet:

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

SwiftData liest beim Anlegen des Containers die aktuelle Schemaversion des persistenten Stores aus, durchläuft die Stages des Plans von dieser Version vorwärts bis zur aktuellen und wendet jede Stage der Reihe nach an.

Was Lightweight-Migrationen automatisch handhaben

Die meisten Schemaänderungen erfordern keine Custom-Stage1:

  • Hinzufügen eines Attributs mit Default-Wert. var foo: Bool = false an einem bestehenden @Model erfolgt automatisch.
  • Hinzufügen einer neuen Entität (Modellklasse). Neue Typen erscheinen, wenn ihr VersionedSchema das aktuelle ist; bestehende Daten bleiben erhalten.
  • Entfernen eines Attributs oder einer Entität. SwiftData verwirft die Spalte oder Tabelle.
  • Umbenennen eines Attributs oder einer Entität. Fügen Sie @Attribute(originalName: "oldName") an die Eigenschaft an, um Daten zu erhalten; SwiftData mappt alt auf neu.
  • Ändern eines Beziehungstyps. Eins-zu-viele, viele-zu-viele usw.
  • Festlegen von Löschregeln. @Relationship(deleteRule: .cascade) und ähnliche Ergänzungen sind lightweight.

Für Änderungen aus dieser Liste lautet das richtige Muster, gar kein neues VersionedSchema zu deklarieren, wenn die Modelltypen ansonsten unverändert sind. SwiftData führt die Lightweight-Migration automatisch gegen das bestehende Schema aus.

Die Falle: Ein Feld hinzuzufügen erfordert kein V2

Der häufigste SwiftData-Migrationsfehler: Ein Entwickler fügt eine neue Eigenschaft mit Inline-Default (var foo: Bool = false) hinzu und deklariert dann ein SchemaV2, das auf dieselben Modelltypen wie SchemaV1 verweist. Der Build ist sauber. Der erste Start auf einem Gerät mit bestehenden V1-Daten stürzt mit Duplicate version checksums across stages detected ab, weil sowohl SchemaV1 als auch SchemaV2 zur selben Prüfsumme aufgelöst werden (die Modelltypen haben sich nicht in einer Weise geändert, die SwiftData als unterschiedlich erkennt).

Das korrekte Muster: Lassen Sie das bestehende VersionedSchema unangetastet, fügen Sie die neue Eigenschaft mit Inline-Default an das Modell an und überlassen Sie SwiftDatas automatischer Lightweight-Migration die Arbeit. Kein MigrationPlan, keine MigrationStage, kein V2 erforderlich.

// 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
    }
}

Die Änderung var isFavorite: Bool = false wird ohne jede MigrationStage-Deklaration ausgeliefert. Der ModelContainer-Initializer, der migrationPlan: nicht übergibt, funktioniert:

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

Das V2-Schema ist nur dann erforderlich, wenn eine Änderung nicht lightweight sein kann (eine Datentransformation, ein Modellsplit, eine Vererbungsumstrukturierung, die Custom-Logik erfordert). In diesen Fällen ist V2 real und ein SchemaMigrationPlan orchestriert den Übergang.

Wann Custom-Migrationen erforderlich sind

Custom-Migrationen rechtfertigen ihre Komplexität in drei Fällen:

1. Aufteilen eines Feldes auf mehrere. Aus einem String-Feld, das "Last, First" enthält, werden zwei Felder, firstName und lastName. Die Migration muss den alten Wert lesen, ihn parsen und die neuen Felder schreiben.

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()
    }
)

Die didMigrate-Closure läuft gegen den Context des neuen Schemas, sodass die neuen Felder zugänglich sind. Das alte fullName muss möglicherweise so lange für die Entfernung zurückgestellt werden, bis die neuen Felder befüllt sind; die Bereinigung ist eine nachgelagerte V2-zu-V3-Stage.

2. Berechnen abgeleiteter Felder. Ein neues @Attribute, das von bestehenden Daten abhängt, muss zur Migrationszeit nachträglich befüllt werden.

3. Verschieben von Daten zwischen Modellen. Eine Reorganisation, bei der Daten aus Item zwischen Item und einem neuen Tag-Modell aufgeteilt werden, erfordert Custom-Logik, um Tags aus den alten Daten zuzuweisen.

Das allgemeine Muster: lightweight, wenn sich die Form des Schemas ändert; custom, wenn sich die Form der Daten ändert.

willMigrate vs. didMigrate

Custom-Stages haben zwei Closures, die zu unterschiedlichen Zeitpunkten aufgerufen werden4:

willMigrate läuft, bevor SwiftData die Schemamigration anwendet. Der Modell-Context, den die Closure erhält, ist der Context des alten Schemas. Verwenden Sie diese Closure, um Daten zu erfassen, sie zu denormalisieren oder Hilfszustand vorzubereiten, bevor sich das Schema darunter ändert.

didMigrate läuft nach der Schemamigration. Der Modell-Context gehört zum neuen Schema. Verwenden Sie sie zum Nachfüllen neuer Felder, zum Berechnen abgeleiteter Daten oder zum Abschließen der Migration.

Beide Closures können nil sein, falls nicht benötigt. Die meisten Custom-Migrationen verwenden nur didMigrate; willMigrate ist nützlich, wenn die Migration alte Daten lesen muss, die nach der Schemaänderung nicht mehr zugänglich sind.

Die Closure erhält einen ModelContext und kann fetchen, ändern und speichern. Die Closure wirft Fehler; Fehler propagieren aus der Migration heraus und brechen sie ab.

iOS 26: Klassenvererbung für @Model

iOS 26 führt Klassenvererbung für SwiftData-Modelle ein2. Modelle können nun Eltern-Kind-Beziehungen haben:

@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)
    }
}

Schemata, die Vererbung übernehmen, springen auf eine neue Version mit einer Lightweight-Migrationsstage von der vorherigen flachen Modellversion. Der Übergang läuft automatisch, wenn die Vererbung die bestehenden Eigenschaften erhält; neue Felder an der Subklasse folgen dem Standardmuster mit Inline-Default.

Das Muster passt zu Fällen, in denen mehrere @Model-Typen Merkmale teilen: ein Vehicle-Elternteil mit den Kindern Car, Truck, Motorcycle; ein Account-Elternteil mit den Kindern CheckingAccount, SavingsAccount. Die geteilten Eigenschaften liegen am Elternteil; die Spezifika liegen an den Kindern.

Migrationen testen

Eine Migration, die kompiliert, ist noch keine Migration, die ausgeliefert werden kann. Drei Testmuster, die sich vor dem Release lohnen:

1. Round-Trip-Test gegen eine Kopie der Produktionsdatenbank. Ziehen Sie eine aktuelle Datenbank in Produktionsform (oder erzeugen Sie synthetische V1-Daten über Tests), öffnen Sie sie mit dem V2-fähigen Container und verifizieren Sie, dass die Daten korrekt migrieren. Der Test fängt Custom-Migrationsfehler ab, die der Type-Checker nicht erkennen kann.

2. Alte Version startet weiterhin. Bauen Sie die vorherige App-Version, führen Sie sie einmal aus, um V1-Daten zu erzeugen, bauen Sie dann die neue App-Version und verifizieren Sie, dass sie ohne Crash startet. Der Test fängt die „Duplicate version checksums”-Falle und ähnliche Deklarationsfehler ab.

3. Wiederherstellung nach fehlgeschlagener Migration. Was passiert, wenn die Migration einen Fehler wirft? Das Verhalten von SwiftData hängt von der Konfiguration des Containers ab; bei produktiven Apps sollte ein nicht behandelter Migrationsfehler nicht stillschweigend Benutzerdaten löschen. Testen Sie den Fehlerpfad explizit und entscheiden Sie, was die App tut (Rollback, Prompt, Wiederherstellung aus Backup).

Der Beitrag des Clusters zum Single-Source-of-Truth-Muster behandelt die verwandte Frage, was passiert, wenn ein SwiftData-Store durch prozessübergreifende Synchronisation ersetzt wird. Migrationen sind das Pendant für lokale Evolution zu diesem Muster.

Häufige Fehlermuster

Drei Muster aus den SwiftData-Fehlerlogs:

Ein V2 für eine Änderung deklarieren, die SwiftData automatisch handhaben würde. Der „Duplicate version checksums”-Crash. Lösung: Deklarieren Sie kein neues Schema für Eigenschaftsergänzungen mit Inline-Default; lassen Sie SwiftData sie automatisch handhaben.

Custom-Migrationscode, der nicht speichert. Eine didMigrate-Closure, die Entitäten ändert, aber context.save() nicht aufruft, erzeugt eine Migration, die einmal läuft, ihre Arbeit verwirft und bei jedem Start erneut läuft (weil die Migration als unvollendet erscheint). Lösung: Jede Closure, die Daten ändert, muss vor der Rückkehr try context.save() aufrufen.

Eine Eigenschaft umbenennen, ohne @Attribute(originalName:). SwiftData behandelt die neue Eigenschaft als neu und die alte als gelöscht; bestehende Daten an der alten Eigenschaft werden verworfen. Lösung: Deklarieren Sie @Attribute(originalName: "oldName") var newName: ..., damit SwiftData die Daten durch die Umbenennung mappt.

Was dieses Muster für iOS-26+-Apps bedeutet

Drei Erkenntnisse.

  1. Standardmäßig keine VersionedSchema-Leiter. Eigenschaften mit Inline-Defaults hinzufügen, ungenutzte Felder löschen, mit @Attribute(originalName:) umbenennen. Alles lightweight und automatisch. Die VersionedSchema-Leiter ist für Änderungen gedacht, die SwiftData wirklich nicht automatisch handhaben kann (Datentransformationen, Custom-Logik, Vererbungsumstrukturierungen).

  2. Verwenden Sie MigrationStage.custom für Datentransformationen, nicht für Schemaformänderungen. Die willMigrate- und didMigrate-Closures sind für Code gedacht, der auf Daten operiert, nicht um zu deklarieren, dass sich das Schema geändert hat. Schemaformänderungen laufen über Lightweight-Stages.

  3. Testen Sie Migrationen mit echten V1-Daten, nicht nur mit synthetischen Testdaten. Migrationen, die bei synthetischen Round-Trips bestehen, können an Daten in Produktionsform mit Edge Cases dennoch scheitern (nullable Felder, die das Schema nicht abdeckte, große Datensätze, die in einen Timeout laufen usw.). Die Kosten des Testens sind gering; die Kosten eines Migrationscrashs beim ersten Start sind real.

Der vollständige Apple-Ecosystem-Cluster: typisierte App Intents; MCP-Server; die Routing-Frage; Foundation Models; die Unterscheidung zwischen Runtime und Tooling LLM; drei Oberflächen; das Single-Source-of-Truth-Muster; Zwei MCP-Server; Hooks für die Apple-Entwicklung; Live Activities; die watchOS-Runtime; SwiftUI-Internals; RealityKits räumliches Mental Model; SwiftData-Schemadisziplin; Liquid-Glass-Muster; Multi-Platform-Auslieferung; die Plattform-Matrix; Vision-Framework; Symbol Effects; Core-ML-Inferenz; Writing Tools API; Swift Testing; Privacy Manifest; Accessibility als Plattform; SF-Pro-Typografie; visionOS-Spatial-Patterns; Speech-Framework; worüber ich mich weigere zu schreiben. Der Hub liegt unter der Apple-Ecosystem-Reihe. Für umfassenderen Kontext zu iOS mit AI-Agenten siehe den iOS-Agent-Development-Guide.

FAQ

Brauche ich immer einen SchemaMigrationPlan?

Nein. Apps mit einer einzigen Schemaversion (das Initial-Release oder Apps, die immer nur Lightweight-Änderungen vorgenommen haben) brauchen keinen SchemaMigrationPlan. Der ModelContainer-Initializer akzeptiert die Modelle des Schemas direkt. Der migrationPlan:-Parameter wird das erste Mal nötig, wenn eine Custom-Migrationsstage deklariert wird (oder wenn der Entwickler erstmals eine explizite Versionsleiter deklarieren möchte).

Woher weiß ich, ob meine Änderung lightweight ist?

Apples Liste der Lightweight-tauglichen Änderungen1: Hinzufügen von Entitäten/Attributen/Beziehungen, deren Entfernen, Umbenennen mit @Attribute(originalName:), Ändern der Beziehungskardinalität, Festlegen von Löschregeln. Wenn die Änderung in eine dieser Kategorien fällt und die Modellklassenstruktur ansonsten unverändert ist, läuft die Migration automatisch und es ist keine VersionedSchema-Leiter erforderlich. Wenn die Änderung eine Datentransformation erfordert (Berechnen, Aufteilen, Verschieben von Daten), ist sie custom.

Können willMigrate und didMigrate beide gesetzt sein?

Ja. Beide Closures sind einzeln optional, können aber auch beide angegeben werden. willMigrate läuft gegen den Context des alten Schemas, bevor SwiftData migriert; didMigrate läuft danach gegen den Context des neuen Schemas. Die beiden decken Vorbereitung beziehungsweise Abschluss ab.

Was passiert, wenn eine Migration einen Fehler wirft?

Der Fehler propagiert aus der ModelContainer-Initialisierung heraus. Der Container schlägt beim Öffnen fehl. Das Verhalten der App hängt davon ab, wie der Entwickler den Fehler behandelt: Manche Apps zeigen eine Recovery-UI, manche versuchen eine Wiederherstellung aus einem Backup, manche löschen den korrupten Store und beginnen frisch. SwiftData löscht bei einem Migrationsfehler nicht stillschweigend Benutzerdaten; mit dem Fehler muss die App selbst umgehen.

Wie teste ich eine Migration, ohne Produktionsdaten zu beeinflussen?

Bauen Sie ein Test-Target, das einen ModelContainer anlegt, der auf eine temporäre Datei-URL zeigt, befüllen Sie ihn mit V1-Daten und öffnen Sie ihn dann mit dem neuen Container, der den Migrationsplan enthält. Verifizieren Sie, dass die migrierten Daten den Erwartungen entsprechen. Das Muster funktioniert sowohl in Unit- als auch in Integrationstests; für möglichst realistische Ergebnisse verwenden Sie eine Kopie einer tatsächlichen Datenbank in Produktionsform.

Funktioniert die Klassenvererbung von iOS 26 mit bestehenden Schemata?

Ja, mit einer Lightweight-Migration. Apps, die Vererbung übernehmen, springen auf eine neue Schemaversion (z. B. V4) und deklarieren eine MigrationStage.lightweight(fromVersion: V3.self, toVersion: V4.self). Die flachen Eigenschaften der Elternklasse bleiben erhalten, und die subklassenspezifischen Eigenschaften werden mit Inline-Defaults hinzugefügt. SwiftDatas Lightweight-Migration handhabt die strukturelle Änderung.

Referenzen


  1. Apple Developer Documentation: Protokollreferenzen VersionedSchema und SchemaMigrationPlan. Das Migrationsmodell. Siehe auch den verwandten Leitfaden Adopting SwiftData for a Core Data app für die vollständige Schema-Evolutions-Erzählung. 

  2. Apple Developer: SwiftData: Dive into inheritance and schema migration (WWDC 2025 Session 291). Die Einführung der SwiftData-Klassenvererbung in iOS 26. 

  3. Apple Developer Documentation: MigrationStage mit den Fällen .lightweight(fromVersion:toVersion:) und .custom(fromVersion:toVersion:willMigrate:didMigrate:)

  4. Apple Developer Documentation: MigrationStage.custom(fromVersion:toVersion:willMigrate:didMigrate:) für die Signatur des Falls. Die Semantik, dass willMigrate gegen den alten Context und didMigrate gegen den neuen Context läuft, ist in WWDC 2025 Session 291 SwiftData: Dive into inheritance and schema migration dokumentiert, derselben Session, die auch für die Vererbungsergänzung in iOS 26 referenziert wird. 

Verwandte Beiträge

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. Lesezeit

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. Lesezeit

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. Lesezeit