Agent Skills: SwiftData & CoreData Persistence

Use when implementing data persistence in iOS apps with SwiftData or CoreData, encountering migration errors, performance issues with fetches, or choosing between persistence frameworks

UncategorizedID: dagba/ios-mcp/swiftdata-coredata-persistence

Install this agent skill to your local

pnpm dlx add-skill https://github.com/dagba/ios-mcp/tree/HEAD/skills/swiftdata-coredata-persistence

Skill Files

Browse the full folder contents for swiftdata-coredata-persistence.

Download Skill

Loading file tree…

skills/swiftdata-coredata-persistence/SKILL.md

Skill Metadata

Name
swiftdata-coredata-persistence
Description
Use when implementing data persistence in iOS apps with SwiftData or CoreData, encountering migration errors, performance issues with fetches, or choosing between persistence frameworks

SwiftData & CoreData Persistence

Overview

SwiftData and CoreData are NOT interchangeable. Each has specific strengths, migration traps, and performance patterns. Wrong framework choice or migration strategy causes production crashes.

Core principle: Choose framework based on app constraints, version models from day one, never store BLOBs in the database.

Framework Selection

digraph framework_choice {
    "New iOS app?" [shape=diamond];
    "iOS 17+ only?" [shape=diamond];
    "Simple data model?" [shape=diamond];
    "iCloud sync?" [shape=diamond];
    "Existing CoreData?" [shape=diamond];
    "Use SwiftData" [shape=box, style=filled, fillcolor=lightgreen];
    "Use CoreData" [shape=box, style=filled, fillcolor=lightblue];
    "Stay with CoreData" [shape=box, style=filled, fillcolor=lightblue];

    "New iOS app?" -> "iOS 17+ only?" [label="yes"];
    "New iOS app?" -> "Existing CoreData?" [label="no"];
    "Existing CoreData?" -> "Stay with CoreData" [label="yes"];
    "Existing CoreData?" -> "iOS 17+ only?" [label="no"];
    "iOS 17+ only?" -> "Simple data model?" [label="yes"];
    "iOS 17+ only?" -> "Use CoreData" [label="no"];
    "Simple data model?" -> "iCloud sync?" [label="yes"];
    "Simple data model?" -> "Use CoreData" [label="no"];
    "iCloud sync?" -> "Use SwiftData" [label="yes"];
    "iCloud sync?" -> "Use SwiftData" [label="no"];
}

Choose SwiftData When

  • New app, iOS 17+ deployment target
  • Simple relationships (mostly 1:1 or 1:N, few complex queries)
  • SwiftUI-first architecture
  • iCloud sync needed with minimal configuration

Choose CoreData When

  • iOS 16 or earlier support required
  • Complex queries with NSCompoundPredicate, subqueries
  • UIKit-heavy app with NSFetchedResultsController
  • Existing CoreData codebase (migration complex, stay with CoreData)
  • Performance critical (as of 2025, CoreData still faster)
  • Abstract entities or inheritance hierarchies

Migration Death Traps

Trap 1: Unversioned → Versioned with iCloud

CRITICAL: If you shipped unversioned SwiftData with iCloud sync, users will crash on first migration.

Error: "Cannot use staged migration with an unknown model version"

Why: CloudKit requires versioned schemas for sync consistency.

Solution:

// ❌ WRONG: Adding versioning after shipping unversioned + iCloud
// This will crash for all existing users

// ✅ CORRECT: Two-phase migration
// Version 1.1: Add versioning WITHOUT schema changes
enum SchemaV1: VersionedSchema {
    static var models: [any PersistentModel.Type] { [User.self] }

    @Model
    final class User {
        var name: String
        var email: String
        // EXACT same schema as shipped v1.0
    }
}

// Version 1.2: Now safe to add new properties
enum SchemaV2: VersionedSchema {
    static var models: [any PersistentModel.Type] { [User.self] }

    @Model
    final class User {
        var name: String
        var email: String
        var createdAt: Date? // New property
    }
}

Rule: Start with versioned schemas from day one, or ship versioning wrapper first.

Trap 2: Unique Constraint with Duplicates

Scenario: Adding @Attribute(.unique) when data has duplicates.

Result: Lightweight migration fails, app becomes non-functional.

Solution: Complex migration with deduplication BEFORE applying constraint.

struct DeduplicateThenUnique: MigrationStage {
    func migrate(context: ModelContext) throws {
        let items = try context.fetch(FetchDescriptor<OldSchema.Product>())
        let grouped = Dictionary(grouping: items, by: { $0.sku })

        // Keep first, delete duplicates
        for (_, duplicates) in grouped where duplicates.count > 1 {
            for item in duplicates.dropFirst() {
                context.delete(item)
            }
        }
        try context.save()
    }
}

Rule: Clean data BEFORE applying constraints. Test with production data backup.

Trap 3: iCloud Sync Blocks Migrations

CRITICAL: Once iCloud sync enabled, SwiftData blocks migrations that break lightweight compatibility.

What this means:

  • Cannot rename properties (breaks sync)
  • Cannot change relationship delete rules (breaks sync)
  • Cannot remove required properties (breaks sync)

Workaround:

// Instead of renaming (blocked):
// @Model class User {
//     var userName: String // Renamed from 'name'
// }

// Add new, deprecate old (allowed):
@Model class User {
    var name: String // Keep for backward compatibility
    var userName: String? // New preferred field

    var displayName: String {
        userName ?? name // Compute from both
    }
}

Rule: With iCloud, only additive changes allowed. Design schema carefully upfront.

Trap 4: Main Thread Migration Blocking

Symptom: White screen for 3-5 seconds on app launch after update.

Cause: Large dataset migration runs on main thread.

Solution: Background initialization.

// ✅ Initialize container on background thread
Task.detached {
    let container = try await ModelContainer(
        for: User.self,
        migrationPlan: MigrationPlan.self
    )
    await MainActor.run {
        self.container = container
    }
}

Rule: Always test migrations with realistic data size (1000+ records).

Performance Patterns

Pattern 1: Batch Fetching

Problem: Loading 1000+ objects at once consumes memory, blocks UI.

Solution: fetchBatchSize for lazy loading.

// ❌ WRONG: Loads all 1000 posts into memory
let request: NSFetchRequest<Post> = Post.fetchRequest()
let posts = try context.fetch(request)

// ✅ CORRECT: Loads 20 at a time as needed
let request: NSFetchRequest<Post> = Post.fetchRequest()
request.fetchBatchSize = 20
let posts = try context.fetch(request)

Rule: Always set fetchBatchSize for feeds, lists, paginated views.

Pattern 2: BLOB Storage

Problem: Storing images/videos in CoreData/SwiftData kills performance.

Rule:

  • < 100KB: Store inline (safe)
  • 100KB - 1MB: Separate entity with relationship
  • > 1MB: File system + store path in database
// ❌ WRONG: 2MB images in Post entity
@Model
class Post {
    var title: String
    @Attribute(.externalStorage) var image: Data // Still loads eagerly!
}

// ✅ CORRECT: File system storage
@Model
class Post {
    var title: String
    var imagePath: String? // "images/post-123.jpg"
}

// Load image on-demand
func loadImage(for post: Post) -> UIImage? {
    guard let path = post.imagePath else { return nil }
    let url = FileManager.documentsDirectory.appendingPathComponent(path)
    return UIImage(contentsOfFile: url.path)
}

Why: Database fetches load full objects. BLOBs inflate memory usage even when not displayed.

Pattern 3: Faulting for Relationships

Problem: Fetching posts eagerly loads all related users and images.

Solution: Return faults, load on access.

// ✅ Relationships default to faults (good)
let request: NSFetchRequest<Post> = Post.fetchRequest()
request.relationshipKeyPathsForPrefetching = [] // Don't prefetch
let posts = try context.fetch(request)

// Access triggers individual fault:
let authorName = posts[0].author.name // Only now loads User

// ✅ Prefetch when you know you'll need it:
request.relationshipKeyPathsForPrefetching = ["author"]

Rule: Only prefetch relationships you'll access for ALL fetched objects.

Pattern 4: NSFetchedResultsController (CoreData)

For: Table/collection views with live updates.

Why: Lazy loads, automatic UI updates, sectioning built-in.

let request: NSFetchRequest<Post> = Post.fetchRequest()
request.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)]
request.fetchBatchSize = 20

let frc = NSFetchedResultsController(
    fetchRequest: request,
    managedObjectContext: context,
    sectionNameKeyPath: nil,
    cacheName: "PostsCache"
)

frc.delegate = self
try frc.performFetch()

// UITableView uses frc.fetchedObjects

Rule: Use FRC for all CoreData-backed table/collection views.

SwiftData Limitations (as of iOS 18)

| Feature | CoreData | SwiftData | Workaround | |---------|----------|-----------|------------| | NSCompoundPredicate | ✅ | ❌ | Multiple queries | | NSFetchedResultsController | ✅ | ❌ | @Query in SwiftUI | | Abstract entities | ✅ | ❌ | Use protocols | | Child contexts | ✅ | ❌ | Use ModelContext | | Subclassing models | ✅ | ❌ | Don't subclass | | Performance | Faster | Slower | Wait for updates |

Common Mistakes

| Mistake | Reality | Fix | |---------|---------|-----| | "Migrations are automatic" | Only lightweight migrations. Unique constraints, renames require complex migrations. | Test with production data | | "I'll version later" | Unversioned + iCloud = crash on first migration | Version from day one | | "SwiftData = CoreData simplified" | SwiftData has different rules (no subclassing, different delete rules) | Learn SwiftData-specific constraints | | "externalStorage attribute fixes BLOBs" | Still loaded into memory on fetch | Store in file system | | "I can rename properties freely" | With iCloud, renames blocked | Add new property, keep old | | "Migration won't block UI" | Large datasets block main thread | Background initialization |

Migration Checklist

Before shipping schema change:

  • [ ] Defined versioned schemas (V1, V2)
  • [ ] Written migration stage if complex (deduplication, renames)
  • [ ] Tested with production data backup (1000+ records)
  • [ ] Tested migration on background thread (no white screen)
  • [ ] Verified iCloud sync works post-migration
  • [ ] Checked new properties have defaults or are optional
  • [ ] Confirmed unique constraints applied to clean data only

Quick Reference

SwiftData:

// Basic model
@Model
class User {
    @Attribute(.unique) var email: String
    var name: String
    @Relationship(deleteRule: .cascade) var posts: [Post]
}

// Versioned schemas
enum SchemaV1: VersionedSchema {
    static var models: [any PersistentModel.Type] { [User.self] }
    @Model final class User { /* ... */ }
}

// Query (SwiftUI)
@Query(sort: \.createdAt, order: .reverse) var users: [User]

CoreData:

// Fetch with batching
let request: NSFetchRequest<User> = User.fetchRequest()
request.fetchBatchSize = 20
request.predicate = NSPredicate(format: "name CONTAINS[cd] %@", searchText)

// Background context
let bgContext = container.newBackgroundContext()
bgContext.perform {
    // Heavy work here
    try? bgContext.save()
}

Red Flags - STOP and Reconsider

  • Shipped unversioned schema with iCloud → Ship versioning wrapper first
  • Adding unique constraint to dirty data → Run deduplication migration
  • Storing multi-MB files in database → Move to file system
  • White screen on launch after update → Background migration
  • Fetch taking >500ms → Add fetchBatchSize, check for BLOBs
  • "I'll handle migration when users complain" → Test NOW with production data

Real-World Impact

Before: App crashes for 100% of users with iCloud enabled after first migration (unversioned→versioned trap).

After: Ship v1.1 with versioning wrapper (no schema change), then v1.2 with actual changes. Zero crashes.


Before: Feed loads in 4 seconds with 1000 posts + 2MB images each.

After: Images moved to file system, fetchBatchSize = 20. Feed loads in 300ms, lazy-loads images.