Skip to main content

Drift Detection

Drift detection catches accidental modifications to frozen schemas before they cause SwiftData crashes in production. It’s your first line of defense against schema-related bugs.

What is Schema Drift?

Schema drift occurs when you accidentally modify a schema version that’s already been frozen and shipped:
// Original frozen v1.0.0
@FreezeSchema(version: "1.0.0")
enum AppSchemaV1: VersionedSchema {
    static var models: [any PersistentModel.Type] {
        [User.self]
    }
}

// ❌ Later: You modify it (DRIFT!)
@FreezeSchema(version: "1.0.0")
enum AppSchemaV1: VersionedSchema {
    static var models: [any PersistentModel.Type] {
        [User.self, Post.self]  // Added Post!
    }
}
This change breaks SwiftData’s migration system because the internal hash no longer matches the shipped version.

Why Drift is Dangerous

SwiftData’s Internal Hashing

SwiftData generates internal hashes for each schema version. When a user opens your app:
  1. SwiftData reads the existing database
  2. Compares the stored schema hash with current schema hash
  3. If hashes match → no migration needed
  4. If hashes don’t match → trigger migration
The problem: If you modify a shipped schema, the hash changes, but SwiftData thinks it’s still v1.0.0. Result:
Cannot use staged migration with an unknown model version

Fresh Installs Hide the Problem

Your simulator tests won’t catch this:
// Simulator (fresh install):
// 1. No existing database
// 2. SwiftData creates NEW database with current (modified) schema
// 3. No hash comparison happens
// ✅ Test passes

// Production user (existing database):
// 1. Database has original v1.0.0 hash
// 2. SwiftData compares with modified v1.0.0 hash
// 3. Hashes don't match → migration fails
// ❌ App crashes on launch

How Drift Detection Works

1. Checksum Generation

When you freeze a schema, FreezeRay generates a SHA256 checksum of the schema structure:
freezeray freeze 1.0.0
# Creates: FreezeRay/Fixtures/1.0.0/schema-1_0_0.json
{
  "version": "1.0.0",
  "checksum": "0cc298858e409d8b9c7e37a7779af0e2",
  "models": [ /* ... */ ]
}
This checksum is stored in both:
  • The JSON fixture file
  • The Swift code (via macro expansion)

2. Drift Test Generation

FreezeRay scaffolds a drift test that runs on every test suite execution:
// AUTO-GENERATED: FreezeRay/Tests/AppSchemaV1_DriftTests.swift

@Test("AppSchemaV1 v1.0.0 has not drifted")
func testAppSchemaV1_1_0_0_Drift() throws {
    // Calls the macro-generated check function
    try AppSchemaV1.__freezeray_check_1_0_0()
}

3. Checksum Comparison

The __freezeray_check_1_0_0() function:
  1. Computes current schema checksum
  2. Compares with frozen checksum
  3. Throws descriptive error if they don’t match
// Macro-generated code (simplified)
extension AppSchemaV1 {
    static func __freezeray_check_1_0_0() throws {
        let frozen = "0cc298858e409d8b9c7e37a7779af0e2"
        let current = computeChecksum()

        guard current == frozen else {
            throw DriftError(
                version: "1.0.0",
                expected: frozen,
                actual: current
            )
        }
    }
}

4. Clear Error Messages

When drift is detected, you get an actionable error:
❌ Schema drift detected in sealed version 1.0.0

Expected checksum: 0cc298858e409d8b9c7e37a7779af0e2
Actual checksum:   26d70c10e9febf7f8c5a9e3d4b2a1f0e

→ Sealed schemas are immutable - create a new schema version instead.

To fix:
  1. Revert changes to AppSchemaV1
  2. Create AppSchemaV2 with your new models
  3. Add migration: AppSchemaV1 → AppSchemaV2
  4. Freeze v2.0.0: freezeray freeze 2.0.0

What Triggers Drift Detection

Drift is detected when you change any part of a frozen schema:

Adding Models

@FreezeSchema(version: "1.0.0")
enum AppSchemaV1: VersionedSchema {
    static var models: [any PersistentModel.Type] {
        [User.self, Post.self]  // ❌ Added Post
    }
}

Removing Models

@FreezeSchema(version: "1.0.0")
enum AppSchemaV1: VersionedSchema {
    static var models: [any PersistentModel.Type] {
        // ❌ Removed User
        [Post.self]
    }
}

Modifying Model Properties

// Inside AppSchemaV1
@Model
final class User {
    var name: String
    var email: String
    var age: Int  // ❌ Added property
}

Changing Property Types

@Model
final class User {
    var name: String
    var email: Int  // ❌ Changed String → Int
}

Modifying Relationships

@Model
final class User {
    var name: String
    @Relationship(deleteRule: .cascade)  // ❌ Added relationship
    var posts: [Post]
}

Changing Attributes

@Model
final class User {
    @Attribute(.unique)  // ❌ Added unique constraint
    var email: String
}

Running Drift Tests

Local Development

Drift tests run automatically with your test suite:
# Run all tests (including drift tests)
xcodebuild test -scheme MyApp \
  -destination 'platform=iOS Simulator,name=iPhone 15'

# Or via Xcode: ⌘U

Continuous Integration

Add drift detection to your CI pipeline:
# .github/workflows/test.yml
name: Test

on: [push, pull_request]

jobs:
  test:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v3
      - name: Run Tests (includes drift detection)
        run: |
          xcodebuild test \
            -scheme MyApp \
            -destination 'platform=iOS Simulator,name=iPhone 15'
Any PR that modifies a frozen schema will fail CI.

Fixing Drift

Step 1: Identify the Drift

Run tests to see which schema drifted:
xcodebuild test -scheme MyApp

# Output:
 Schema drift detected in sealed version 1.0.0
Expected checksum: 0cc298858e409d8b...
Actual checksum:   26d70c10e9febf7f...

Step 2: Revert Changes

Undo modifications to the frozen schema:
git diff  # See what changed
git checkout AppSchemaV1.swift  # Revert

Step 3: Create New Version

Create a new schema version with your changes:
// Keep V1 unchanged
@FreezeSchema(version: "1.0.0")
enum AppSchemaV1: VersionedSchema {
    static var models: [any PersistentModel.Type] {
        [User.self]  // Original models only
    }
}

// New V2 with your changes
@FreezeSchema(version: "2.0.0")
enum AppSchemaV2: VersionedSchema {
    static var versionIdentifier = Schema.Version(2, 0, 0)

    static var models: [any PersistentModel.Type] {
        [User.self, Post.self]  // Now includes Post
    }
}

Step 4: Add Migration

Define how to migrate from V1 → V2:
enum AppMigrations: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [AppSchemaV1.self, AppSchemaV2.self]
    }

    static var stages: [MigrationStage] {
        [migrateV1toV2]
    }

    static let migrateV1toV2 = MigrationStage.lightweight(
        fromVersion: AppSchemaV1.self,
        toVersion: AppSchemaV2.self
    )
}

Step 5: Freeze New Version

freezeray freeze 2.0.0
git add FreezeRay/
git commit -m "Add schema v2.0.0 with Post model"

Best Practices

Never use —force to overwrite fixtures. It defeats the purpose of drift detection.

Run Tests Before Every Commit

# Pre-commit hook (.git/hooks/pre-commit)
#!/bin/bash
xcodebuild test -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 15'
if [ $? -ne 0 ]; then
    echo "Tests failed (possible schema drift detected)"
    exit 1
fi

Fail Fast in CI

Configure CI to fail immediately on drift:
- name: Drift Detection
  run: xcodebuild test -scheme MyApp -only-testing:MyAppTests/DriftTests

Use Type Aliases

Type aliases help prevent accidental modifications:
// In your app code, use type aliases
typealias User = DataV2.User

// If you need to modify User:
// 1. Create DataV3.User (new schema)
// 2. Update typealias: typealias User = DataV3.User
// 3. DataV2.User remains unchanged

Edge Cases

Intentional Schema Updates (Dev Only)

During development, you might want to update an unfrozen schema:
// V2 not frozen yet - safe to modify
@FreezeSchema(version: "2.0.0")
enum AppSchemaV2: VersionedSchema { /* ... */ }
No drift test exists yet, so modifications won’t trigger errors. Once you freeze it, the schema becomes immutable.

Force Overwriting (Last Resort)

If you absolutely must refreeze (e.g., fixtures corrupted):
freezeray freeze 1.0.0 --force
Only use --force if you haven’t shipped v1.0.0 to production. Otherwise, you’ll break existing users.

Troubleshooting

False Positives

Symptom: Drift detected but schema hasn’t changed Causes:
  • File encoding changes (LF vs CRLF)
  • Whitespace differences
  • Comment changes
Fix: Drift detection only looks at semantic structure, not formatting. If you get false positives, file a bug report.

Drift Not Detected

Symptom: Modified frozen schema but tests pass Causes:
  • Drift test not added to test target
  • Test file not committed to git
  • Test target not building
Fix:
# Verify drift test exists
ls FreezeRay/Tests/*_DriftTests.swift

# Verify test target includes drift tests
xcodebuild -showBuildSettings -scheme MyApp | grep TEST_HOST

Next Steps