SwiftData migrations: 5 patterns that actually work
SwiftData migrations are simpler than Core Data's, but they still bite if you're not deliberate. After running migrations across 30+ shipped apps, here are the five patterns we keep reaching for - and the one case where we just rebuild the store.
The mental model
SwiftData migrations follow a numbered schema: SchemaV1, SchemaV2, etc. Each schema is a snapshot of your @Model types. A MigrationPlan declares the order and the (optional) custom migration steps.
Lightweight changes (adding an optional property, renaming with @Attribute(originalName:)) need no custom code. Anything more - splitting models, restructuring relationships, fixing data - needs a custom migration stage.
Pattern 1: lightweight property addition
By far the most common change. Add an optional property to the model. SwiftData writes it as nil for existing rows. Done.
Bump SchemaV1 to SchemaV2 in your VersionedSchema enum and add a MigrationStage.lightweight entry. Total code: 4 lines.
Pattern 2: renaming a property
Use @Attribute(originalName: "oldName") on the new property. SwiftData translates the old column to the new name without any custom migration code.
Caveat: this only works for property renames, not type changes. If you need String → UUID, that's pattern 5.
Pattern 3: splitting one model into two
We hit this when our Habit Tracker Kit grew an Entry model out of what was originally a denormalized list on Habit. The migration: write a custom MigrationStage.custom that iterates the old Habit objects, materializes Entry rows, and clears the old field.
The trick is to do this in one transaction with explicit save points. SwiftData's willMigrate closure runs before the schema is converted; didMigrate runs after. We do the write in didMigrate with the new types.
Pattern 4: extracting a relationship
If your old model had a String? for category and the new design wants a proper Category entity, the migration creates the new Category rows on demand and points each row at one.
Common pitfall: forgetting to handle duplicates. We always upsert - fetch by name, create if missing, then assign.
Pattern 5: data fixup migration
Sometimes the migration isn't structural - it's just fixing bad data. A user might have entries with weight: 0 from a buggy build. The migration walks every entry and either repairs it or marks it for review.
We always log every fixup with os.Logger for the first few minutes after launch. Pre-shipping migrations are the place users will hit one weird case you didn't anticipate.
When to give up and rebuild
If your model has changed shape three times across two majors and the migration plan is becoming a maze, consider just resetting the store and importing from a JSON export instead. Our import/export helpers (see our studio toolkit) are designed for exactly this.
We've used this exit twice in two years. Both times it was the right call - clearer code, faster path, zero data lost. SwiftData isn't sacred.
Tooling we use
- Xcode CloudKit Console for inspecting CloudKit-synced records during dev
- A debug menu that exports the current store as JSON (mentioned in our starter)
- Snapshot tests of model migrations using a small seeded fixture file checked into the repo
- A 'reset to last good version' debug action - invaluable when iterating on migration code