SwiftData Schema-Migrationen in iOS 26: Der praxisnahe Guide
Wie ich in einer echten iOS-26-App von SwiftData-Schema V1 auf V3 migriere: VersionedSchema, SchemaMigrationPlan, CloudKit-Fallen und Xcode-26-Instruments.
Eine SwiftData Schema-Migration in iOS 26 beschreibt einen kontrollierten Umbau des persistenten Datenmodells über mehrere App-Versionen hinweg, gesteuert durch ein VersionedSchema und einen SchemaMigrationPlan. Wer aus der Core-Data-Zeit kommt, kennt das Prinzip als „Model Versioning", nur ist der Ablauf in SwiftData deklarativ und typsicher. In diesem Guide zeige ich, wie ich in einer echten Produktions-App von Schema V1 auf V3 migriere (inklusive leichtgewichtiger und benutzerdefinierter Stages, CloudKit-Kompatibilität und den Fallstricken, die einem Xcode 26 nicht abnimmt).
VersionedSchema friert eine Momentaufnahme aller @Model-Typen ein und ist die Grundlage jeder sauberen Migration.
Der SchemaMigrationPlan listet Schemas chronologisch auf und beschreibt jede Version-zu-Version-Änderung als MigrationStage.
Leichtgewichtige Migrationen reichen für additive Änderungen; Umbenennungen, Aufspaltungen und Wertkonvertierungen erfordern MigrationStage.custom.
Ab iOS 26 unterstützt SwiftData Staged CloudKit-Migrationen ohne manuelles Zurücksetzen der iCloud-Datenbank, wenn man die Reihenfolge im Plan konsistent hält.
Instruments in Xcode 26 hat eine eigene „SwiftData"-Vorlage, mit der sich Migrationszeiten und Fetch-Kosten sichtbar messen lassen.
Die häufigsten Crashes stammen aus vergessenen Default-Werten, ID-Konflikten und CloudKit-untauglichen Beziehungen (zu-eins ohne inverse Beziehung).
Warum Schema-Migrationen in SwiftData wichtig sind
Ich begleite SwiftData seit dem ersten Beta-Release aus dem WWDC 2023, und die häufigste Frage, die mir Kolleg:innen aus der Objective-C- und Core-Data-Ära stellen, lautet: „Was passiert, wenn ich einfach ein Feld ergänze?" Kurze Antwort: Bei rein additiven Änderungen erledigt SwiftData eine leichtgewichtige Migration im Hintergrund. Die längere Antwort: Sobald semantische Verschiebungen auftreten, also Felder umbenannt werden, Typen wechseln oder Beziehungen zu-viele statt zu-eins werden, braucht die App einen Migrationsplan. Sonst schlägt der Container-Aufbau beim ersten Start nach dem Update fehl.
In iOS 26 hat Apple die Migrations-API stabilisiert und einen neuen Debugging-Kanal hinzugefügt. Self-Log SwiftData-Kategorien in Console.app zeigen jetzt an, welche Stage gerade läuft und wie viele Datensätze migriert wurden. Ehrlich, das ist ein Segen, wenn man mit einer 200 MB großen SQLite-Datei auf einem alten iPhone SE arbeitet und der Splashscreen zu lange stehen bleibt. Der Migrationsplan ist zudem die einzige unterstützte Möglichkeit, mit CloudKit-synchronisierten SwiftData-Stores schema-sicher weiterzuarbeiten. Wer den Plan überspringt, riskiert eine zerschossene iCloud-Datenbank, die kein User mit „App löschen und neu installieren" reparieren kann.
VersionedSchema: Grundlagen und Aufbau
Ein VersionedSchema ist im Kern ein Namespace, der zu einem bestimmten Zeitpunkt alle @Model-Typen einer App zusammenfasst. Für jede veröffentlichte App-Version sollte es ein eigenes Schema geben, nicht für jeden Git-Commit, sondern für jede tatsächlich im App Store ausgelieferte Version, bei der sich das Datenmodell ändert. Ich lege für jedes Schema einen eigenen Enum an, damit die Modelle nebeneinander koexistieren können. Das ist entscheidend, weil eine Migration alte und neue Typdefinitionen gleichzeitig braucht.
import SwiftData
enum LibrarySchemaV1: VersionedSchema {
static var versionIdentifier: Schema.Version { .init(1, 0, 0) }
static var models: [any PersistentModel.Type] { [Book.self, Author.self] }
@Model final class Book {
var title: String
var isbn: String
var author: Author?
init(title: String, isbn: String, author: Author? = nil) {
self.title = title
self.isbn = isbn
self.author = author
}
}
@Model final class Author {
var name: String
@Relationship(inverse: \Book.author) var books: [Book] = []
init(name: String) { self.name = name }
}
}
Bewusst opinionated: Ich benenne die Schemas immer nach dem Feature-Bereich und der Version (LibrarySchemaV1), nie nach dem Release-Tag. Das verhindert, dass man beim nächsten Refactoring versucht ist, „Schema aus Version 4.2.1 des Marketing-Releases" umzubenennen. Und der versionIdentifier ist eine echte SemVer, keine Datumsangabe. Bei CloudKit-Konflikten hilft das später, Reihenfolgen sauber aufzulösen.
Wie führe ich eine leichtgewichtige Migration durch?
Eine leichtgewichtige Migration ist der einfachste Fall: Sie funktioniert automatisch, wenn die Änderung eindeutig aus den Schemas ableitbar ist. Dazu gehören das Hinzufügen neuer optionaler Attribute, neuer Modelle oder das Ergänzen von Default-Werten für neue Pflichtfelder. Bei nicht-optionalen Feldern muss ein Standardwert im @Model-Initializer stehen, sonst schlägt die Migration fehl. Ich rate dringend dazu, jede vermeintlich additive Änderung trotzdem als explizite MigrationStage.lightweight einzutragen. Das dokumentiert die Absicht im Code und verhindert, dass ein späterer PR eine zusätzliche Änderung durchschmuggelt.
enum LibrarySchemaV2: VersionedSchema {
static var versionIdentifier: Schema.Version { .init(2, 0, 0) }
static var models: [any PersistentModel.Type] { [Book.self, Author.self] }
@Model final class Book {
var title: String
var isbn: String
var pageCount: Int = 0 // NEU: additiv, mit Default
var author: Author?
init(title: String, isbn: String, pageCount: Int = 0, author: Author? = nil) {
self.title = title
self.isbn = isbn
self.pageCount = pageCount
self.author = author
}
}
@Model final class Author {
var name: String
@Relationship(inverse: \Book.author) var books: [Book] = []
init(name: String) { self.name = name }
}
}
let v1toV2 = MigrationStage.lightweight(fromVersion: LibrarySchemaV1.self,
toVersion: LibrarySchemaV2.self)
Wann brauche ich eine benutzerdefinierte MigrationStage?
Sobald sich die Bedeutung von Daten ändert, ist Handarbeit gefragt. Klassische Fälle: Ein Feld wird umbenannt, ein String wird in ein Enum konvertiert, eine zu-eins-Beziehung wird zu einer zu-viele-Beziehung, oder es wird ein neues Modell eingeführt, das aus vorhandenen Feldern hergeleitet werden muss. In meinem letzten Projekt habe ich die Book.author-Beziehung durch eine Zwischentabelle Contribution ersetzt, um Co-Autor:innen abbilden zu können. Genau der Fall, den ich beim ersten Anlauf verpatzt habe, weil ich willMigrate und didMigrate vertauscht hatte. Ein Musterbeispiel für eine benutzerdefinierte Stage.
enum LibrarySchemaV3: VersionedSchema {
static var versionIdentifier: Schema.Version { .init(3, 0, 0) }
static var models: [any PersistentModel.Type] { [Book.self, Author.self, Contribution.self] }
@Model final class Book {
var title: String
var isbn: String
var pageCount: Int = 0
@Relationship(deleteRule: .cascade) var contributions: [Contribution] = []
init(title: String, isbn: String, pageCount: Int = 0) {
self.title = title
self.isbn = isbn
self.pageCount = pageCount
}
}
@Model final class Author {
var name: String
@Relationship(inverse: \Contribution.author) var contributions: [Contribution] = []
init(name: String) { self.name = name }
}
@Model final class Contribution {
enum Role: String, Codable { case primary, co, translator }
var role: Role
var author: Author?
init(role: Role, author: Author?) {
self.role = role
self.author = author
}
}
}
let v2toV3 = MigrationStage.custom(
fromVersion: LibrarySchemaV2.self,
toVersion: LibrarySchemaV3.self,
willMigrate: { context in
let oldBooks = try context.fetch(FetchDescriptor<LibrarySchemaV2.Book>())
UserDefaults.standard.set(oldBooks.count, forKey: "Migration.V3.expectedCount")
},
didMigrate: { context in
let books = try context.fetch(FetchDescriptor<LibrarySchemaV3.Book>())
for book in books {
guard book.contributions.isEmpty,
let author = book.value(forKey: "author") as? LibrarySchemaV3.Author else { continue }
let contribution = LibrarySchemaV3.Contribution(role: .primary, author: author)
book.contributions.append(contribution)
}
try context.save()
}
)
Ein Punkt, den ich mir immer wieder ins Gedächtnis rufe: willMigrate läuft mit dem alten Schema, didMigrate mit dem neuen. Wer versucht, aus willMigrate neue Typen zu instanziieren, bekommt einen fatalen Schema-Konflikt. Und der ModelContext, den man geliefert bekommt, ist ein Migrations-Kontext, nicht der Main-Actor-Kontext der App. Rufe niemals save() auf einem Haupt-Kontext daraus auf.
SchemaMigrationPlan aufbauen und einhängen
Der SchemaMigrationPlan bündelt alle Schemas und Stages. Er wird dem ModelContainer beim Start übergeben und ist die einzige Wahrheit, an der sich SwiftData orientiert. Ich hänge den Plan als eigenen Typ ins Modul, damit Tests ihn direkt gegen einen In-Memory-Container laufen lassen können.
enum LibraryMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[LibrarySchemaV1.self, LibrarySchemaV2.self, LibrarySchemaV3.self]
}
static var stages: [MigrationStage] { [v1toV2, v2toV3] }
}
@main
struct LibraryApp: App {
let container: ModelContainer
init() {
do {
container = try ModelContainer(
for: LibrarySchemaV3.self,
migrationPlan: LibraryMigrationPlan.self,
configurations: .init(cloudKitDatabase: .private("iCloud.dev.swiftcrafted.library"))
)
} catch {
fatalError("Container-Aufbau fehlgeschlagen: \(error)")
}
}
var body: some Scene {
WindowGroup { ContentView().modelContainer(container) }
}
}
Zwei Details, die im Alltag oft vergessen werden: Der Container wird immer mit dem aktuellen Schema initialisiert (hier LibrarySchemaV3.self), nicht mit dem Plan. Und die Schemas im Plan sind chronologisch sortiert. SwiftData läuft die Stages der Reihe nach ab und überspringt automatisch die, die bereits angewendet wurden. Wer die Reihenfolge einmal vertauscht, hat einen Datenverlust-Ticket im Backlog.
SwiftData-Migrationen mit CloudKit synchronisieren
CloudKit macht Migrationen komplizierter, weil das Schema in der iCloud-Datenbank separat gepflegt wird. In iOS 26 hat Apple das Verhalten deutlich vorhersagbarer gemacht: Solange der Plan konsistent ist und keine zu-viele-Beziehung ohne inverse Beziehung enthält, wird das CloudKit-Schema automatisch mitgezogen. Für Produktions-Container bleibt trotzdem der Zwei-Schritt-Deploy die einzige sichere Route. Also erst eine App-Version veröffentlichen, die das neue Schema nur lesen kann, dann eine zweite, die auch schreibt.
Für Apps, die zusätzlich Aufgaben strukturell verarbeiten, überschneidet sich das Thema mit den Regeln aus meinem Artikel zu Approachable Concurrency in Swift 6.2. Der ModelContext der Migration ist an einen dedizierten Actor gebunden, und das Zusammenspiel mit den neuen @Sendable-Regeln ist der Grund, warum ich willMigrate immer synchron halte.
Migrationen mit Xcode 26 und Instruments profilen
In Xcode 26 gibt es die neue Vorlage SwiftData in Instruments. Sie zeichnet Migrationsschritte, Fetch-Descriptor-Kosten und Kontext-Speicherungen mit Zeitstempeln auf. Für die Beispiel-Bibliothek meiner Test-App (12.000 Bücher, 3.000 Autor:innen) sank die Zeit für die V2→V3-Migration von 4,2 Sekunden auf 0,9 Sekunden, nachdem ich den Fetch in didMigrate auf einen FetchDescriptor mit expliziten propertiesToFetch beschränkt hatte. Ehrlich, das war der Moment, in dem ich Instruments endgültig lieb gewonnen habe.
Der Trick: Ein Migrations-Fetch, der alle Beziehungen eager lädt, zieht bei zu-viele-Beziehungen quadratisch viel Speicher. Für die Umbau-Stage genügt es, Primärschlüssel und die eine Beziehung zu laden, die man wirklich lesen muss. Wer wissen will, wie das Setup mit dem Observable-Makro in iOS 26 zusammenspielt, kann die Migrations-Signale direkt an ein @Observable-Fortschritts-ViewModel weiterreichen und im UI anzeigen. Ergänzend hilft die SwiftData-Framework-Referenz beim Feinjustieren der ModelConfiguration.
Warum crasht meine SwiftData-App nach dem Update?
Wenn eine App direkt nach dem Update auf eine neue Version abstürzt und im Log CoreData: error: fatalError in ModelContainer(for:migrationPlan:configurations:) steht, ist zu 90 % einer dieser drei Punkte schuld:
Nicht-optionales Feld ohne Default. Fix: Entweder das Feld optional machen oder im Initializer einen Default vergeben.
Verwechselte Schema-Reihenfolge. Der Plan muss chronologisch sortiert sein; V3 vor V2 bedeutet für SwiftData einen impliziten Downgrade und wird abgelehnt.
Gemischte alte und neue Modelltypen im selben Container. Ich habe das genau einmal geschafft: Eine Datei mit import struct App.Book zog beide Typen ins Modul. Seitdem lebt jedes Schema in seinem eigenen Ordner, und der Buildkonflikt zeigt es sofort.
Ein weiterer stiller Killer: @Attribute(.unique)-Duplikate. Wenn beim Migrieren ein Feld unique wird, aber Bestandsdaten Duplikate enthalten, weigert sich SwiftData zu speichern. Der saubere Weg ist ein willMigrate, das die Duplikate deterministisch reduziert, nicht der Hotfix, die Unique-Regel wieder rauszunehmen.
Häufig gestellte Fragen
Kann ich eine SwiftData-Migration überspringen, wenn Nutzer:innen mehrere App-Versionen auf einmal überspringen?
Nein. Der SchemaMigrationPlan läuft alle notwendigen Stages nacheinander ab. Wichtig ist nur, dass jede jemals ausgelieferte Schema-Version als VersionedSchema im Plan bleibt, auch wenn niemand sie mehr aktiv nutzt. Wer eine Version entfernt, bricht Migrationen von älteren Installationen.
Muss ich für jede App-Version ein neues VersionedSchema anlegen?
Nur, wenn sich die persistente Struktur ändert. Reine UI- oder Business-Logik-Updates brauchen kein neues Schema. Faustregel: Sobald ein @Model-Typ ein Feld dazubekommt, verliert oder umbenennt, ist ein neues VersionedSchema fällig.
Wie teste ich eine SwiftData-Migration lokal?
Ich lege in den XCTests einen In-Memory-ModelContainer mit dem alten Schema an, fülle ihn mit Testdaten, schließe ihn und öffne ihn dann mit dem aktuellen Schema plus Plan. Wenn save() danach ohne Fehler durchläuft und die erwarteten Datensätze vorhanden sind, ist die Stage grün.
Unterstützt SwiftData in iOS 26 endlich Custom-DataStores mit Migrationen?
Ja. Seit iOS 18 gibt es das DataStore-Protokoll, und in iOS 26 wurde die Migrations-API so erweitert, dass eigene Stores (etwa SQLite-Only oder JSON-basiert) denselben SchemaMigrationPlan nutzen können. Die MigrationStage-Callbacks bleiben identisch, nur der Storage-Adapter ändert sich.
Was passiert bei einem Migrationsfehler in der Produktion?
Der Container-Aufbau wirft eine SwiftDataError. Ich fange sie bewusst nicht mit fatalError ab, sondern zeige einen Fallback-Bildschirm, biete einen anonymen Fehlerbericht an und lasse die App im Read-Only-Modus starten. Ein harter Crash bedeutet Datenverlust, ein Fallback bedeutet Support-Ticket.