DEV Community

Sebastien Lato
Sebastien Lato

Posted on

SwiftUI Data Migration & Schema Evolution (Safe Upgrades Without Data Loss)

Most apps ship updates like this:

UserDefaults.standard.set(true, forKey: "newFlag")
Enter fullscreen mode Exit fullscreen mode

That works…

until you need:

  • database schema changes
  • new required fields
  • offline data preservation
  • sync queue compatibility
  • multi-version clients in the wild
  • rollback safety
  • tenant-specific migrations

At that point, data migration becomes one of the riskiest parts of your app.

This post shows how to design a migration system in SwiftUI that is:

  • safe
  • deterministic
  • backward-compatible
  • testable
  • production-grade

🧠 The Core Principle

App versions change β€” user data must survive.

Your schema is a contract with every version you’ve ever shipped.


🧱 1. Version Your Data Schema

Never rely on implicit structure.

struct SchemaVersion: Codable {
    let version: Int
}
Enter fullscreen mode Exit fullscreen mode

Persist current version:

let currentSchemaVersion = 3
Enter fullscreen mode Exit fullscreen mode

🧬 2. Migration Engine Lives in Infrastructure

final class MigrationEngine {
    private let persistence: PersistenceLayer
    private let migrations: [MigrationStep]
}
Enter fullscreen mode Exit fullscreen mode

It does not live in:

  • Views
  • ViewModels
  • feature modules

Migration is cross-cutting infrastructure.


πŸ” 3. Define Migration Steps

struct MigrationStep {
    let from: Int
    let to: Int
    let migrate: () throws -> Void
}
Enter fullscreen mode Exit fullscreen mode

Example:

MigrationStep(from: 1, to: 2) {
    addField("lastOpenedAt", default: Date())
}
Enter fullscreen mode Exit fullscreen mode

Migrations must be:

  • ordered
  • idempotent
  • atomic

πŸ“¦ 4. Run Migrations at App Launch

func performMigrations() throws {
    let storedVersion = loadVersion()
    for step in migrations where step.from == storedVersion {
        try step.migrate()
        saveVersion(step.to)
    }
}
Enter fullscreen mode Exit fullscreen mode

Run before:

  • sync engine starts
  • ViewModels load data
  • background tasks resume

⚠️ 5. Backward Compatibility Matters

Users may skip versions.

Migration path must support:

v1 β†’ v4
Enter fullscreen mode Exit fullscreen mode

Not only:

v3 β†’ v4
Enter fullscreen mode Exit fullscreen mode

πŸ”„ 6. Migration & Sync Queue Compatibility

If schema changes:

  • queued operations may become invalid
  • payload formats may change

Strategy:

migrateQueuedOperations()
Enter fullscreen mode Exit fullscreen mode

Never drop user actions silently.


πŸ”‹ 7. Safe Rollback Strategy

App updates fail in the real world.

Rules:

  • never delete old columns immediately
  • support dual-read during transition
  • remove fields only after several versions

This prevents catastrophic data loss.


πŸ§ͺ 8. Testing Migrations

Test scenarios:

  • upgrade from every prior version
  • partial migrations
  • corrupted data
  • interrupted migrations

Migration bugs are permanent.


⚠️ 9. Common Migration Anti-Patterns

Avoid:

  • destructive schema changes
  • silent data resets
  • skipping version tracking
  • running migrations lazily
  • mixing migration with feature logic

These lead to:

  • data loss
  • sync failures
  • inconsistent state

🧠 Mental Model

Think:

Old Schema
 β†’ Migration Engine
   β†’ New Schema
     β†’ Sync Engine
       β†’ App Features
Enter fullscreen mode Exit fullscreen mode

Not:

β€œWe’ll just reset the database.”


πŸš€ Final Thoughts

A proper migration system gives you:

  • safe app upgrades
  • preserved user data
  • compatibility with old client
  • fewer production incidents
  • confidence to evolve your schema

This is the difference between:

  • a fragile app
  • and a long-lived product

Top comments (0)