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:
- SwiftData reads the existing database
- Compares the stored schema hash with current schema hash
- If hashes match → no migration needed
- 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:
- Computes current schema checksum
- Compares with frozen checksum
- 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