Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 030 / 100

Core Data vs SwiftData: a practical guide for developers

Choosing a persistence layer.

Expona Admin8 min read

Introduction

By day 30 of building iOS apps, you'll likely face a fundamental choice: which persistence layer should your app use? Core Data vs SwiftData: a practical guide for developers tackles one of the most common architectural decisions we make when moving beyond in-memory storage. Both frameworks solve the same problem—persisting data to disk—but they offer very different developer experiences and trade-offs.

In this guide, we'll walk through the key differences, help you understand when each makes sense, and show you how to migrate gradually if your codebase needs to evolve. We've shipped production apps using both approaches, and the choice often comes down to your deployment target and how much control you need over the persistence layer.

SwiftData: Modern, Swift-First, iOS 17+

SwiftData arrived in iOS 17 as Apple's answer to modern Swift development. It's built from the ground up to feel native to Swift, leveraging macros and value types in ways that Core Data simply cannot. If your team is targeting iOS 17 and above, SwiftData should be your default choice.

The biggest advantage is simplicity. You define your model using regular Swift classes with the @Model macro. There's no need to manage separate Core Data entity definitions or deal with NSFetchRequest boilerplate. Here's what a typical SwiftData model looks like:

swift
import SwiftData

@Model
final class User {
    var id: UUID
    var name: String
    var email: String
    var createdAt: Date
    @Relationship(deleteRule: .cascade) var posts: [Post] = []
    
    init(id: UUID = UUID(), name: String, email: String, createdAt: Date = Date()) {
        self.id = id
        self.name = name
        self.email = email
        self.createdAt = createdAt
    }
}

@Model
final class Post {
    var title: String
    var content: String
    var user: User?
    
    init(title: String, content: String, user: User? = nil) {
        self.title = title
        self.content = content
        self.user = user
    }
}

Notice how clean this is. No XML, no separate entity descriptions, no Objective-C remnants. The @Model macro handles schema inference and persistence setup for you. Relationships are explicit through the @Relationship macro, and delete rules cascade naturally.

SwiftUI integration is seamless. You can query data using the @Query property wrapper, which automatically manages fetching and updates:

swift
import SwiftUI

struct UserListView: View {
    @Query(sort: \.createdAt, order: .reverse) var users: [User]
    @Environment(\.modelContext) var modelContext
    
    var body: some View {
        List {
            ForEach(users) { user in
                VStack(alignment: .leading) {
                    Text(user.name).font(.headline)
                    Text(user.email).font(.caption).foregroundColor(.secondary)
                }
            }
            .onDelete(perform: deleteUsers)
        }
    }
    
    private func deleteUsers(at offsets: IndexSet) {
        for index in offsets {
            modelContext.delete(users[index])
        }
    }
}

The @Query macro handles sorting, filtering, and animations. Changes to the underlying data automatically refresh the view. This is the SwiftUI integration we've wanted for years.

However, SwiftData's youth means you may hit edge cases with complex schemas or specific persistence requirements. The framework is still maturing, and some teams have reported issues with large datasets or intricate relationship patterns. If you need features like custom persistent store types or low-level NSPredicate optimization, you might find SwiftData lacking.

Core Data: Mature, More Control

Core Data has been around since the early days of iOS, and it shows in both maturity and complexity. If you're deploying to iOS 16 or earlier, or if you need absolute control over your persistence layer, Core Data remains the right choice.

The model definition requires an Xcode data model file (.xcdatamodeld), where you configure entities, attributes, and relationships through the graphical interface or XML. Here's what a typical Core Data setup looks like in code:

swift
import CoreData

@NSManaged
class UserEntity: NSManagedObject {
    @NSManaged var id: UUID
    @NSManaged var name: String
    @NSManaged var email: String
    @NSManaged var createdAt: Date
    @NSManaged var posts: NSSet?
}

extension UserEntity {
    @nonobjc class func fetchRequest() -> NSFetchRequest {
        return NSFetchRequest(entityName: "User")
    }
    
    var postsArray: [PostEntity] {
        let set = posts as? Set ?? []
        return set.sorted { $0.createdAt ?? Date() > $1.createdAt ?? Date() }
    }
}

class CoreDataStack {
    static let shared = CoreDataStack()
    
    let persistentContainer: NSPersistentContainer
    
    init() {
        persistentContainer = NSPersistentContainer(name: "MyApp")
        persistentContainer.loadPersistentStores { _, error in
            if let error = error {
                print("Core Data error: \(error)")
            }
        }
    }
    
    var context: NSManagedObjectContext {
        persistentContainer.viewContext
    }
}

Querying in Core Data requires NSFetchRequest and manual setup:

swift
func fetchUsers(for email: String) -> [UserEntity] {
    let request: NSFetchRequest = UserEntity.fetchRequest()
    request.predicate = NSPredicate(format: "email == %@", email)
    request.sortDescriptors = [NSSortDescriptor(keyPath: \UserEntity.createdAt, ascending: false)]
    
    do {
        return try CoreDataStack.shared.context.fetch(request)
    } catch {
        print("Fetch error: \(error)")
        return []
    }
}

This is more verbose, but it gives you fine-grained control. You can tune fetch requests, manage object lifecycles explicitly, and implement custom validation. We've used this control to optimize complex multi-step migrations in production apps that handle millions of records.

Core Data also supports features SwiftData doesn't yet expose: custom persistent store coordinators, iCloud sync via CloudKit, and granular concurrency models. If you need to sync data across devices or implement complex conflict resolution, Core Data's maturity is invaluable.

SwiftData Is Built on Core Data

This is the crucial detail that shapes your strategy. SwiftData isn't a replacement in the traditional sense—it's a high-level abstraction built on top of Core Data. Under the hood, SwiftData uses the same NSPersistentContainer, the same on-disk SQLite store, and the same query engine.

What this means for you: SwiftData is Core Data with better defaults and less boilerplate. The performance characteristics are largely the same. The data format is compatible. A SwiftData store can be read by Core Data code, and vice versa, though doing so requires care.

This architecture also means SwiftData inherits Core Data's limitations. Fetching 100,000 rows still costs memory. Relationship traversal in complex graphs still requires careful query design. The main difference is that SwiftData makes the right patterns easier to implement and the wrong patterns harder to accidentally create.

In practical terms, if you have a Core Data app and you migrate to SwiftData later, you're not starting over. Your data is still there. Your schema still applies. You're just changing how you interact with it at the application level.

Migration Strategies: Gradual Evolution

You won't necessarily need to rewrite your entire app at once. We've successfully run mixed Core Data and SwiftData code in the same process during transitions:

swift
// In an app with Core Data, you can read with SwiftData queries
// by working with the shared ModelContext

@Query var users: [User]  // SwiftData query
let coreDataContext = CoreDataStack.shared.context  // Core Data context

// Both can coexist if you manage carefully
modelContext.insert(newUser)
try? modelContext.save()

// Later fetch with Core Data if needed
let legacyRequest: NSFetchRequest = UserEntity.fetchRequest()
let legacyUsers = try? coreDataContext.fetch(legacyRequest)

A gradual migration might look like this:

  1. Keep Core Data as your backing store. Define corresponding SwiftData models that map to your entities.
  2. Start new features using SwiftData queries and updates.
  3. Migrate view layers to use @Query as you touch them.
  4. Once the UI is fully SwiftData, migrate your persistence layer if your minimum deployment target allows it.

This approach lets you adopt SwiftData incrementally without blocking feature work. We've used it on apps with two-year release cycles where we couldn't jump to iOS 17 immediately but still wanted the developer experience benefits.

Common Mistakes

Assuming SwiftData and Core Data are interchangeable: They're not, not yet. iOS 16 support means Core Data. If you choose SwiftData, you're committing to iOS 17+. This matters for app store penetration and corporate deployments.

Ignoring memory pressure with large datasets: Both frameworks load data into memory. Fetching all 50,000 customers at once will crash, whether you use Core Data or SwiftData. Use predicates to filter before fetching.

Not versioning your Core Data schema: If you're still using Core Data in production, light migrations become heavy operations. Plan schema versions early, even if your app is young.

Treating SwiftData as a complete replacement: SwiftData is excellent for typical CRUD operations. But if you need CloudKit sync, custom validation hooks, or legacy system integration, Core Data's maturity might still be essential. We've seen teams commit to SwiftData only to discover missing features mid-project.

Fetching in the main thread without pagination: Whether Core Data or SwiftData, large fetches block the UI. Always paginate or use background contexts for heavy lifting.

Key Takeaways

  • Use SwiftData for new iOS 17+ projects. It's simpler, more Swifty, and requires less boilerplate than Core Data.
  • Stick with Core Data if you need iOS 16 or earlier support, or if you require advanced features like custom persistence stores or CloudKit integration.
  • Understand that SwiftData is built on Core Data. You're choosing a level of abstraction, not a fundamentally different architecture.
  • Plan for gradual migration if you have an existing Core Data codebase. You can mix queries and updates during the transition.
  • In both frameworks, performance comes from smart query design: use predicates to filter early, paginate large results, and avoid unnecessary relationship traversal.
  • Test your persistence layer with realistic data volumes. What works with 100 records might fail with 100,000.
  • Monitor Core Data's debug options (com.apple.CoreData.SQLDebug) to understand query execution, even when using SwiftData.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles