NewAppLander — App landing pages in 60s$69$39
The Swift Kit logoThe Swift Kit
Tutorial

SwiftUI Core Data Tutorial for Beginners — CRUD in 30 Minutes

A complete, beginner-friendly guide to Core Data in SwiftUI. Set up your data model, wire up NSPersistentContainer, and build a full Notes app with Create, Read, Update, and Delete — all in about 30 minutes.

Ahmed GaganAhmed Gagan
14 min read

TL;DR

Core Data is Apple's battle-tested persistence framework. In this tutorial you will create an Xcode data model, set up NSPersistentContainer, inject the context into SwiftUI via the environment, and build a complete Notes app with Create, Read (using @FetchRequest), Update, and Delete — all in about 30 minutes. Every code block is copy-pasteable. If you want the modern alternative, read our SwiftData vs Core Data comparison first.

Core Data has been around since 2005. Frameworks come and go, but Core Data keeps showing up in production codebases — including Apple's own apps like Notes, Reminders, and Health. If you are building an iOS app that needs to store structured data locally, Core Data remains the most mature option available. This SwiftUI Core Data tutorial walks you through every step from zero to a fully working CRUD app. No prior Core Data knowledge required.

What Is Core Data and Why Should You Still Learn It?

Core Data is not a database. It is an object-graph management framework that happens to persist data to SQLite by default. The distinction matters: Core Data manages the lifecycle of your objects in memory, tracks changes, handles undo/redo, and lazily loads data from disk. SQLite is just the storage backend.

You might be wondering: "Apple introduced SwiftData — should I skip Core Data entirely?" Here is when Core Data is still the right choice in 2026:

  • You need to support iOS 15 or iOS 16 — SwiftData requires iOS 17+. If your user base includes older devices, Core Data is your only built-in option.
  • You are working on an existing Core Data app — Migrating to SwiftData has real cost. Core Data is not deprecated and continues to receive maintenance updates.
  • You need batch operations on large datasetsNSBatchInsertRequest, NSBatchUpdateRequest, and NSBatchDeleteRequest operate directly on SQLite without loading objects into memory. SwiftData has no equivalent yet.
  • You want maximum control over faulting and memory — Core Data gives you fine-grained control over when objects are loaded, prefetched, and released.
  • Job market — Most existing iOS codebases use Core Data. Knowing it makes you employable on day one at companies with established apps.

For a detailed side-by-side, read our SwiftData vs Core Data comparison. But if you are here to learn Core Data, let us get started.

How Do You Set Up Core Data in a SwiftUI Project?

There are two ways to start: check the "Use Core Data" box when creating a new Xcode project, or add Core Data to an existing project manually. I will show you the manual approach because it teaches you what each piece actually does.

Step 1: Create the Data Model File

In Xcode, go to File → New → File and choose Data Model (under the Core Data section). Name it NotesModel.xcdatamodeld. This file is a visual editor where you define your entities (tables), attributes (columns), and relationships.

Step 2: Create the Persistence Controller

The persistence controller wraps NSPersistentContainer and provides the managed object context to your app. Create a new Swift file called PersistenceController.swift:

import CoreData

struct PersistenceController {
    static let shared = PersistenceController()

    let container: NSPersistentContainer

    init(inMemory: Bool = false) {
        container = NSPersistentContainer(name: "NotesModel")

        if inMemory {
            container.persistentStoreDescriptions.first!
                .url = URL(fileURLWithPath: "/dev/null")
        }

        container.loadPersistentStores { _, error in
            if let error = error as NSError? {
                // In production, handle this gracefully
                fatalError("Core Data failed to load: \(error)")
            }
        }

        container.viewContext.automaticallyMergesChangesFromParent = true
        container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
    }

    // Preview helper for SwiftUI previews
    static var preview: PersistenceController = {
        let controller = PersistenceController(inMemory: true)
        let context = controller.container.viewContext

        // Seed sample data
        for i in 1...5 {
            let note = NSEntityDescription.insertNewObject(
                forEntityName: "Note",
                into: context
            )
            note.setValue("Sample Note \(i)", forKey: "title")
            note.setValue("This is sample content for note \(i).", forKey: "content")
            note.setValue(Date(), forKey: "createdAt")
        }

        try? context.save()
        return controller
    }()
}

A few things to note: the inMemory parameter is critical for SwiftUI previews and unit tests. By pointing the store at /dev/null, data lives only in RAM and is discarded when the app closes. The automaticallyMergesChangesFromParent flag ensures background saves propagate to the view context without manual merging.

Step 3: Inject the Context into SwiftUI

Open your App struct and inject the managed object context into the environment:

import SwiftUI

@main
struct NotesApp: App {
    let persistence = PersistenceController.shared

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(
                    \.managedObjectContext,
                    persistence.container.viewContext
                )
        }
    }
}

Every view in your app can now access the context via @Environment(\.managedObjectContext). This is the standard SwiftUI pattern and it works seamlessly with @FetchRequest.

How Do You Create Entities and Attributes?

Open NotesModel.xcdatamodeld in Xcode. You will see the data model editor — a visual tool for defining your schema.

Define the Note Entity

  1. Click the Add Entity button at the bottom of the editor.
  2. Rename the entity from "Entity" to Note.
  3. In the Attributes section, click + to add attributes:
AttributeTypeOptionalPurpose
idUUIDNoUnique identifier for each note
titleStringNoNote title displayed in the list
contentStringYesNote body text
createdAtDateNoTimestamp for sorting

Generate the NSManagedObject Subclass

Select the Note entity, open the Data Model Inspector on the right, and set Codegen to Class Definition. Xcode will automatically generate an NSManagedObject subclass at build time. You do not need to create the file yourself — just use Note as a type in your SwiftUI code and it will resolve at compile time.

If you prefer manual control (useful for adding computed properties or custom validation), set Codegen to Manual/None and choose Editor → Create NSManagedObject Subclass. For this tutorial, Class Definition is the simplest path.

How Do You Create (Insert) Records?

Creating a new Core Data record involves three steps: create the managed object, set its properties, and save the context. Here is a function you can call from any SwiftUI view:

func addNote(
    title: String,
    content: String,
    context: NSManagedObjectContext
) {
    let newNote = Note(context: context)
    newNote.id = UUID()
    newNote.title = title
    newNote.content = content
    newNote.createdAt = Date()

    do {
        try context.save()
    } catch {
        print("Failed to save note: \(error.localizedDescription)")
    }
}

The key thing to understand: Note(context: context) creates the object and inserts it into the context in one step. The object exists in memory immediately, but it is not persisted until you call context.save(). If your app crashes before saving, the data is lost.

Error handling matters. In production, do not just print errors. Show an alert to the user, log to your analytics service, or retry the save. Core Data save failures are rare but real — they can happen due to validation constraints, disk space issues, or migration conflicts.

Calling It from a SwiftUI View

struct AddNoteView: View {
    @Environment(\.managedObjectContext) private var context
    @Environment(\.dismiss) private var dismiss

    @State private var title = ""
    @State private var content = ""

    var body: some View {
        NavigationStack {
            Form {
                TextField("Title", text: $title)
                TextEditor(text: $content)
                    .frame(minHeight: 200)
            }
            .navigationTitle("New Note")
            .toolbar {
                ToolbarItem(placement: .confirmationAction) {
                    Button("Save") {
                        addNote(
                            title: title,
                            content: content,
                            context: context
                        )
                        dismiss()
                    }
                    .disabled(title.isEmpty)
                }
                ToolbarItem(placement: .cancellationAction) {
                    Button("Cancel") { dismiss() }
                }
            }
        }
    }
}

How Do You Read (Fetch) Records with @FetchRequest?

@FetchRequest is Core Data's SwiftUI property wrapper. It executes a fetch request, observes changes, and automatically re-renders your view when data changes. No manual refresh needed.

struct NotesListView: View {
    @Environment(\.managedObjectContext) private var context

    @FetchRequest(
        sortDescriptors: [
            NSSortDescriptor(keyPath: \Note.createdAt, ascending: false)
        ],
        animation: .default
    )
    private var notes: FetchedResults<Note>

    @State private var showingAddNote = false

    var body: some View {
        NavigationStack {
            List {
                ForEach(notes) { note in
                    NavigationLink {
                        EditNoteView(note: note)
                    } label: {
                        VStack(alignment: .leading, spacing: 4) {
                            Text(note.title ?? "Untitled")
                                .font(.headline)
                            Text(note.createdAt ?? Date(), style: .date)
                                .font(.caption)
                                .foregroundStyle(.secondary)
                        }
                    }
                }
                .onDelete(perform: deleteNotes)
            }
            .navigationTitle("Notes")
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    Button {
                        showingAddNote = true
                    } label: {
                        Label("Add", systemImage: "plus")
                    }
                }
            }
            .sheet(isPresented: $showingAddNote) {
                AddNoteView()
            }
            .overlay {
                if notes.isEmpty {
                    ContentUnavailableView(
                        "No Notes",
                        systemImage: "note.text",
                        description: Text("Tap + to create your first note.")
                    )
                }
            }
        }
    }

    private func deleteNotes(at offsets: IndexSet) {
        offsets.map { notes[$0] }.forEach(context.delete)
        do {
            try context.save()
        } catch {
            print("Failed to delete: \(error.localizedDescription)")
        }
    }
}

Adding Predicates for Filtering

You can filter results by passing an NSPredicate to @FetchRequest. For example, to show only notes containing a search term:

@FetchRequest(
    sortDescriptors: [
        NSSortDescriptor(keyPath: \Note.createdAt, ascending: false)
    ],
    predicate: NSPredicate(
        format: "title CONTAINS[cd] %@", searchText
    )
)
private var filteredNotes: FetchedResults<Note>

The [cd] modifier makes the search case-insensitive and diacritic-insensitive. For dynamic search (where the predicate changes as the user types), you can update the fetch request's nsPredicate property directly.

How Do You Update Existing Records?

Updating a Core Data object is straightforward: modify its properties, then save the context. Because managed objects are reference types (classes, not structs), changes are tracked automatically by the context.

struct EditNoteView: View {
    @ObservedObject var note: Note
    @Environment(\.managedObjectContext) private var context
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        Form {
            TextField("Title", text: Binding(
                get: { note.title ?? "" },
                set: { note.title = $0 }
            ))
            TextEditor(text: Binding(
                get: { note.content ?? "" },
                set: { note.content = $0 }
            ))
            .frame(minHeight: 200)
        }
        .navigationTitle("Edit Note")
        .toolbar {
            ToolbarItem(placement: .confirmationAction) {
                Button("Save") {
                    saveChanges()
                    dismiss()
                }
            }
        }
    }

    private func saveChanges() {
        do {
            try context.save()
        } catch {
            print("Failed to update: \(error.localizedDescription)")
        }
    }
}

Notice that we use @ObservedObject on the Note — this is because NSManagedObject conforms to ObservableObject. When you modify its properties, the view re-renders automatically. The Binding wrapper handles the optional String values that Core Data generates.

Important: you do not need to "re-fetch" the object after modifying it. The context tracks all changes in memory. When you call context.save(), the changes are persisted to SQLite. If you navigate back to the list, @FetchRequest will already reflect the updated data because it observes the same context.

How Do You Delete Records?

Deleting a single object is simple:

context.delete(note)

do {
    try context.save()
} catch {
    print("Failed to delete: \(error.localizedDescription)")
}

The object is removed from the context immediately, and the deletion is persisted when you save. If your list uses @FetchRequest, the row disappears automatically with the animation you specified.

Batch Delete for Performance

If you need to delete thousands of records (for example, clearing a cache), loading every object into memory just to delete it is wasteful. Core Data provides NSBatchDeleteRequest for this:

func deleteAllNotes(context: NSManagedObjectContext) {
    let fetchRequest: NSFetchRequest<NSFetchRequestResult> =
        Note.fetchRequest()
    let batchDelete = NSBatchDeleteRequest(fetchRequest: fetchRequest)
    batchDelete.resultType = .resultTypeObjectIDs

    do {
        let result = try context.execute(batchDelete)
            as? NSBatchDeleteResult
        let objectIDs = result?.result as? [NSManagedObjectID] ?? []

        // Merge deletions into the view context
        let changes = [
            NSDeletedObjectsKey: objectIDs
        ]
        NSManagedObjectContext.mergeChanges(
            fromRemoteContextSave: changes,
            into: [context]
        )
    } catch {
        print("Batch delete failed: \(error.localizedDescription)")
    }
}

Batch deletes bypass the context entirely — they execute directly on SQLite. That is why you need the mergeChanges call: it tells the in-memory context that objects have been removed so @FetchRequest updates correctly. This is one of Core Data's key performance advantages over SwiftData, which has no batch operation equivalent yet.

Complete CRUD Example: A Working Notes App

Let us bring everything together. Below is the complete, copy-pasteable code for a Notes app with add, list, edit, and delete functionality. You need the PersistenceController from earlier, the NotesModel.xcdatamodeld with a Note entity (id: UUID, title: String, content: String, createdAt: Date), and Codegen set to Class Definition.

App Entry Point

import SwiftUI

@main
struct NotesApp: App {
    let persistence = PersistenceController.shared

    var body: some Scene {
        WindowGroup {
            NotesListView()
                .environment(
                    \.managedObjectContext,
                    persistence.container.viewContext
                )
        }
    }
}

Notes List View (Read + Delete)

import SwiftUI

struct NotesListView: View {
    @Environment(\.managedObjectContext) private var context
    @State private var showingAddNote = false

    @FetchRequest(
        sortDescriptors: [
            NSSortDescriptor(
                keyPath: \Note.createdAt,
                ascending: false
            )
        ],
        animation: .default
    )
    private var notes: FetchedResults<Note>

    var body: some View {
        NavigationStack {
            List {
                ForEach(notes) { note in
                    NavigationLink {
                        EditNoteView(note: note)
                    } label: {
                        VStack(alignment: .leading, spacing: 4) {
                            Text(note.title ?? "Untitled")
                                .font(.headline)
                            if let content = note.content,
                               !content.isEmpty {
                                Text(content)
                                    .font(.subheadline)
                                    .foregroundStyle(.secondary)
                                    .lineLimit(2)
                            }
                            Text(
                                note.createdAt ?? Date(),
                                style: .date
                            )
                            .font(.caption2)
                            .foregroundStyle(.tertiary)
                        }
                        .padding(.vertical, 4)
                    }
                }
                .onDelete(perform: deleteNotes)
            }
            .navigationTitle("Notes")
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    Button {
                        showingAddNote = true
                    } label: {
                        Label("Add Note", systemImage: "plus")
                    }
                }
            }
            .sheet(isPresented: $showingAddNote) {
                AddNoteView()
            }
            .overlay {
                if notes.isEmpty {
                    ContentUnavailableView(
                        "No Notes Yet",
                        systemImage: "note.text",
                        description: Text(
                            "Tap + to create your first note."
                        )
                    )
                }
            }
        }
    }

    private func deleteNotes(at offsets: IndexSet) {
        offsets.map { notes[$0] }.forEach(context.delete)
        do {
            try context.save()
        } catch {
            print("Delete failed: \(error.localizedDescription)")
        }
    }
}

Add Note View (Create)

import SwiftUI

struct AddNoteView: View {
    @Environment(\.managedObjectContext) private var context
    @Environment(\.dismiss) private var dismiss

    @State private var title = ""
    @State private var content = ""

    var body: some View {
        NavigationStack {
            Form {
                Section("Title") {
                    TextField("Note title", text: $title)
                }
                Section("Content") {
                    TextEditor(text: $content)
                        .frame(minHeight: 200)
                }
            }
            .navigationTitle("New Note")
            .toolbar {
                ToolbarItem(placement: .confirmationAction) {
                    Button("Save") {
                        let note = Note(context: context)
                        note.id = UUID()
                        note.title = title
                        note.content = content
                        note.createdAt = Date()

                        do {
                            try context.save()
                        } catch {
                            print(
                                "Save failed: "
                                + "\(error.localizedDescription)"
                            )
                        }
                        dismiss()
                    }
                    .disabled(title.trimmingCharacters(
                        in: .whitespaces
                    ).isEmpty)
                }
                ToolbarItem(placement: .cancellationAction) {
                    Button("Cancel") { dismiss() }
                }
            }
        }
    }
}

Edit Note View (Update)

import SwiftUI

struct EditNoteView: View {
    @ObservedObject var note: Note
    @Environment(\.managedObjectContext) private var context

    var body: some View {
        Form {
            Section("Title") {
                TextField("Title", text: Binding(
                    get: { note.title ?? "" },
                    set: { note.title = $0 }
                ))
            }
            Section("Content") {
                TextEditor(text: Binding(
                    get: { note.content ?? "" },
                    set: { note.content = $0 }
                ))
                .frame(minHeight: 200)
            }
        }
        .navigationTitle("Edit Note")
        .onDisappear {
            if context.hasChanges {
                do {
                    try context.save()
                } catch {
                    print(
                        "Update failed: "
                        + "\(error.localizedDescription)"
                    )
                }
            }
        }
    }
}

That is the complete app. Four files plus the data model: PersistenceController.swift, NotesApp.swift, NotesListView.swift, AddNoteView.swift, and EditNoteView.swift. Every CRUD operation is covered. Build and run — you should have a working Notes app.

Core Data vs SwiftData vs UserDefaults vs File System — When to Use Each

Core Data is not always the right tool. Here is a quick comparison to help you choose:

ApproachBest ForData SizeQueryableRelationships
UserDefaultsSettings, flags, small preferencesTiny (KBs)NoNo
File System (JSON/Plist)Documents, exports, simple Codable modelsSmall-MediumNo (full load)Manual
Core DataStructured data, complex queries, large datasetsAny sizeYes (NSPredicate)Built-in
SwiftDataSame as Core Data, with modern Swift API (iOS 17+)Any sizeYes (#Predicate)Built-in
KeychainSecrets: tokens, passwords, API keysTiny (KBs)NoNo
Cloud (Supabase/Firebase)Multi-device sync, shared data, server-side logicAny sizeYes (SQL/Firestore)Yes

Rule of thumb: if you need to query, filter, or sort structured data — use Core Data (or SwiftData). If you just need to save a handful of settings, UserDefaults is fine. If you need data synced across devices or a real backend, pair local persistence with Supabase or Firebase.

Common Core Data Mistakes Beginners Make

After using Core Data across multiple production apps, here are the mistakes I see beginners make most often:

  1. Not saving the context. Creating an object does not persist it. You must call context.save(). If your app crashes or the user kills it before saving, the data is gone. Save after every meaningful user action.
  2. Accessing managed objects on the wrong thread. A managed object is bound to the queue of its context. Passing a Note from a background context to the main thread causes crashes. Always use context.perform {} for background work, or fetch the object again on the correct context using its objectID.
  3. Ignoring optional attributes. Core Data generates optional properties by default. Force-unwrapping note.title! throughout your views is a crash waiting to happen. Use nil coalescing (note.title ?? "Untitled") or the Binding pattern shown in the edit view above.
  4. Not using inMemory for previews. SwiftUI previews that hit the real SQLite store are slow, flaky, and share state between preview runs. Always use an in-memory store for previews and tests.
  5. Skipping mergePolicy. If you use background contexts (and you should for heavy operations), merge conflicts are inevitable. Set NSMergeByPropertyObjectTrumpMergePolicy on your view context to automatically resolve conflicts in favor of the latest change.
  6. Loading everything into memory. Fetching 10,000 objects just to display 20 on screen wastes memory. Use fetchBatchSize on your fetch request (a value of 20 is a good default) and let Core Data's faulting mechanism handle lazy loading.
  7. Not setting up lightweight migration. When you add a new attribute to an entity, Core Data needs to migrate the existing SQLite file. Lightweight migration handles this automatically for additive changes, but you must enable it. By default, NSPersistentContainer has lightweight migration enabled — but if you customize your store description, do not forget to set NSMigratePersistentStoresAutomaticallyOption and NSInferMappingModelAutomaticallyOption to true.

Skip the Boilerplate — The Swift Kit Handles Persistence for You

You just learned how to set up Core Data from scratch, build a persistence controller, wire up the environment, and implement full CRUD operations. That is roughly 30 minutes of tutorial — and probably 2-3 hours of real work when you account for debugging, testing, and polishing.

The Swift Kit ships with persistence patterns pre-configured alongside onboarding flows, authentication (Sign in with Apple + Supabase), RevenueCat paywalls, analytics, and AI integrations. Instead of spending your first week on infrastructure, you start building the features that make your app unique.

If you want to go deeper on architecture, read our MVVM architecture guide for how to structure your persistence layer with proper dependency injection. For performance tuning, check out SwiftUI performance optimization tips. And if you are deciding between local and cloud storage, the Supabase SwiftUI tutorial covers the cloud side of the equation.

Head to pricing to grab The Swift Kit and start shipping faster.

Share this article

Ready to ship your iOS app faster?

The Swift Kit gives you a production-ready SwiftUI codebase with onboarding, paywalls, auth, AI integrations, and more. Stop building boilerplate. Start building your product.

Get The Swift Kit