APIs & Data Management

Chapter 9
Data Modeling with SwiftData and Core Data

Certificate in IOS Development
APIs & Data Management   •   Certificate

Data Modeling with SwiftData and Core Data

Introduction

Most useful iOS applications manage information that must outlive a single screen or launch. A notes application stores notes, a finance application stores transactions, and a booking application stores customers, reservations, and payments. Persistence is the process of storing this information so that it can be retrieved later. Data modeling is the design of the entities, attributes, relationships, constraints, and operations that give persisted data a reliable structure.

SwiftData is Apple’s modern persistence framework for Swift. Core Data is the mature framework on which many existing applications depend. Both provide object graph management, persistence contexts, querying, change tracking, and integration with Apple platforms. They are not simply SQL wrappers: developers work primarily with Swift objects, while the framework manages storage, identity, faults, and persistence.

A sound model improves correctness, testability, performance, and the ability to evolve an application. A poor model can produce duplicate records, slow queries, accidental data loss, migration failures, and confusing relationships.

Learning Objectives

By the end of this chapter, you should be able to:

  • explain entities, attributes, relationships, identity, and cardinality;
  • create a basic SwiftData model and persistence container;
  • describe the corresponding concepts in Core Data;
  • insert, query, update, and delete records;
  • use predicates, sorting, uniqueness, and indexing appropriately;
  • explain the role of a persistence context;
  • distinguish optionality, delete rules, and inverse relationships;
  • identify migration and data-integrity risks; and
  • select practical modeling strategies for small iOS applications.

The Relational and Object Models

A relational database represents data in tables. A table contains rows, and each row contains values in columns. An entity in an object model is broadly comparable to a table, an object to a row, and an attribute to a column. A relationship connects objects without requiring application code to manually store and interpret every foreign key.

The analogy is useful but incomplete. SwiftData and Core Data manage an object graph: objects have identity, relationships may be navigated in memory, and changes are tracked by a context. Storage may use SQLite, but the application should normally interact through the framework rather than directly manipulating the underlying store.

Essential modeling terms

TermMeaning
EntityA category of persisted objects, such as Note or Customer
AttributeA value belonging to an entity, such as title or createdAt
RelationshipA connection between entities, such as Customer–Order
CardinalityThe number of related objects: one-to-one, one-to-many, or many-to-many
OptionalityWhether a value or relationship may be absent
IdentityThe property that distinguishes one object from another
PredicateA logical condition used to filter query results
ContextAn environment that tracks objects and pending changes
Persistence containerThe configured stack that connects models, contexts, and storage
MigrationThe controlled process of changing a stored model version

Attributes and types

An attribute should represent one coherent fact. For example, a Task might contain title, isCompleted, priority, and dueDate. A title should not contain several unrelated values separated by commas. Such denormalized data is difficult to validate and query.

Common SwiftData attribute types include String, numeric types, Bool, Date, and certain transformable or Codable values. Core Data offers corresponding types such as String, Integer 16/32/64, Double, Decimal, Boolean, Date, Binary Data, and Transformable.

Choose types according to meaning. Use Date for an instant in time rather than a formatted string. Store a stable identifier such as UUID when external synchronization or reliable identity is required. Avoid storing presentation-specific text when the underlying value is structured; for example, store a numeric amount and currency code rather than only “£12.50”.

Relationships and cardinality

Suppose a project contains many tasks, while each task belongs to one project. This is a one-to-many relationship:

  • Project.tasks: zero or more tasks;
  • Task.project: zero or one project, or exactly one if the business rule requires it.

A many-to-many relationship occurs when students enroll in many courses and each course has many students. Frameworks represent this using collections on both sides, but the underlying store must still maintain association information.

An inverse relationship tells the framework which property represents the other side. If a task’s project is changed, the project’s tasks collection can remain consistent when the inverse is correctly modeled. Delete rules specify what happens when an object is deleted:

  • Nullify: remove the relationship from related objects;
  • Cascade: delete dependent objects as well;
  • Deny: prevent deletion while dependent objects exist;
  • No action: leave related references unchanged; this is risky and should be used only with a clear reason.

A cascade rule is appropriate for genuinely owned children, such as a shopping cart and its line items. It is dangerous for shared objects such as users or categories.

SwiftData Modeling

SwiftData uses Swift macros to describe model classes. A minimal model might be:

import SwiftData

@Model
final class Note {
    var title: String
    var body: String
    var createdAt: Date
    var isArchived: Bool

    init(title: String, body: String, createdAt: Date = .now) {
        self.title = title
        self.body = body
        self.createdAt = createdAt
        self.isArchived = false
    }
}

@Model makes the class persistable and enables change tracking. In a SwiftUI application, a container is commonly attached to the application:

@main
struct NotesApp: App {
    var body: some Scene {
        WindowGroup {
            NoteListView()
        }
        .modelContainer(for: Note.self)
    }
}

A view can obtain a model context and query records:

struct NoteListView: View {
    @Environment(\.modelContext) private var modelContext
    @Query(sort: \.createdAt, order: .reverse)
    private var notes: [Note]

    var body: some View {
        List {
            ForEach(notes) { note in
                Text(note.title)
            }
            .onDelete { indexes in
                for index in indexes {
                    modelContext.delete(notes[index])
                }
                try? modelContext.save()
            }
        }
    }
}

Insertion is explicit. The object is created and inserted into a context; saving makes the change durable according to the configured store:

let note = Note(title: "Meeting", body: "Review the API design")
modelContext.insert(note)
try modelContext.save()

In many SwiftUI configurations, autosave reduces the need for frequent explicit saves, but production code should still define an error-handling policy. A save can fail because of validation, storage failure, migration problems, or a conflict.

Relationships in SwiftData

A relationship can be expressed directly in model classes:

@Model
final class Project {
    var name: String
    @Relationship(deleteRule: .cascade, inverse: \Task.project)
    var tasks: [Task] = []

    init(name: String) {
        self.name = name
    }
}

@Model
final class Task {
    var title: String
    var project: Project?

    init(title: String, project: Project? = nil) {
        self.title = title
        self.project = project
    }
}

The exact relationship declaration should be checked against the SDK version used by the project, because framework APIs evolve. The modeling principles remain stable: specify ownership, inverse navigation, optionality, and deletion behavior deliberately.

Core Data Concepts

Core Data generally uses an NSManagedObjectModel, NSPersistentContainer, NSManagedObjectContext, and managed-object subclasses or dynamic entities. A model can be designed in Xcode’s data model editor or created programmatically.

A typical stack is conceptually arranged as follows:

Managed object classes / fetch requests
                ↓
       NSManagedObjectContext
                ↓
       NSPersistentContainer
                ↓
   Persistent store coordinator
                ↓
       SQLite or another store

A context is a unit of work. It registers managed objects, tracks inserted, changed, and deleted states, and coordinates fetches. Contexts are not global databases. They are associated with a queue, and their objects should be used according to that queue’s concurrency rules.

A Core Data fetch commonly uses NSFetchRequest:

let request = Note.fetchRequest()
request.predicate = NSPredicate(format: "isArchived == NO")
request.sortDescriptors = [
    NSSortDescriptor(key: #keyPath(Note.createdAt), ascending: false)
]

let activeNotes = try context.fetch(request)

SwiftData’s FetchDescriptor and #Predicate provide a more Swift-oriented approach, while Core Data’s NSPredicate remains common in established codebases.

Querying, Updating, and Deleting

A query should express the smallest useful result set. Filtering, sorting, and limiting at the persistence layer avoids loading unnecessary objects into memory. For example, an archive screen may need only records where isArchived is true, sorted by date.

A predicate is a Boolean condition. For a record r, a filter can be understood as selecting the set:

R' = { r ∈ R | P(r) = true }

Here, R is the set of stored records, P is the predicate, and R' is the result set. This notation describes logical filtering, not a performance guarantee; actual performance depends on indexes, store configuration, and query translation.

SwiftData example:

let descriptor = FetchDescriptor<Note>(
    predicate: #Predicate { note in
        !note.isArchived && note.title.localizedStandardContains("api")
    },
    sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
)

let matchingNotes = try modelContext.fetch(descriptor)

An update normally consists of changing a tracked property and saving:

note.isArchived = true
try modelContext.save()

Deletion should be intentional, especially where cascade rules exist:

modelContext.delete(note)
try modelContext.save()

Do not use deletion as a substitute for archiving when records are needed for audit, synchronization, or recovery. A soft-delete attribute such as deletedAt can preserve history, but it requires every relevant query to exclude deleted records.

Identity, Uniqueness, and Indexing

Identity answers “which object is this?” A framework-managed object identifier is useful internally, but applications often need a stable application identifier. A UUID is a common choice for synchronization and import/export.

Uniqueness prevents two records from representing the same logical item. For example, an external service may assign a stable remoteID. If remoteID is unique, repeated synchronization can update an existing object rather than insert a duplicate. Uniqueness is a database constraint, not merely a convention in application code. Application-side checks alone can fail under concurrency or after interrupted operations.

SwiftData can express uniqueness using a uniqueness constraint, for example:

@Model
final class Contact {
    @Attribute(.unique) var remoteID: String
    var displayName: String

    init(remoteID: String, displayName: String) {
        self.remoteID = remoteID
        self.displayName = displayName
    }
}

The exact behavior of conflicts should be tested for the target OS and store configuration. A unique attribute does not automatically solve all synchronization conflicts; it identifies duplicate identity, while conflict resolution determines which values prevail.

An index speeds retrieval for commonly searched or sorted fields by maintaining an additional lookup structure. It may reduce query work but increases storage and write overhead. Index fields that are frequently used in selective predicates, joins, or sorting—not every attribute. Indexing a low-value Boolean field often provides little benefit because its two possible values are not very selective.

Persistence Contexts and Concurrency

A context provides isolation for a unit of work. In a user interface, a main-actor context supports UI-bound operations. Background contexts are useful for imports, large transformations, or synchronization. Managed objects should not be passed casually between contexts; pass stable identifiers or values, then refetch in the destination context.

A reliable background workflow is:

  1. create or obtain a background context;
  2. perform the fetch and changes on its permitted queue;
  3. save the background context;
  4. merge or refresh changes in the UI context according to the framework configuration; and
  5. handle errors and conflicts explicitly.

Concurrency bugs can cause crashes, stale displays, lost updates, or inconsistent relationships. Swift concurrency and actor isolation improve compile-time guidance, but developers must still understand which context owns an object and where it may be used.

Schema Evolution and Migration

A model is a contract with existing users. Adding a nonoptional attribute without a default value can make old records invalid. Renaming an attribute without a mapping can appear as deletion plus insertion, potentially losing data. Changing an attribute’s type may require a staged migration.

Migration strategies include:

  • Lightweight migration: the framework infers a compatible change, such as adding an optional attribute or a simple relationship change;
  • Custom migration: developer-defined mapping or transformation handles complex changes; and
  • Staged migration: several carefully tested model versions are applied in sequence.

Migration planning should include versioned models, backups where appropriate, test stores containing realistic old data, and rollback or recovery procedures. A migration is not complete merely because a fresh installation works; existing installations are the important test case.

Typical migration questions include:

  • What happens to existing records?
  • What default value is assigned to newly required data?
  • Are external identifiers preserved?
  • Do uniqueness constraints reject historical duplicates?
  • Can the application open the store after an interrupted migration?

Normalization and Integrity

Relational normalization reduces duplication and update anomalies. At a basic level, store each fact once and represent repeated or multi-valued facts through related entities. For example, storing a customer’s address separately from every order avoids copying the same address into many rows—but historical orders may intentionally need a snapshot of the address used at purchase time. Thus normalization is a design tool, not an absolute rule.

Integrity rules should be enforced at several levels:

  • types and optionals prevent invalid representations;
  • model validation checks business constraints;
  • uniqueness prevents duplicate identity;
  • relationships and delete rules preserve graph consistency; and
  • server-side validation remains necessary when data is synchronized externally.

Never assume local persistence is a security boundary. A device can be backed up, inspected, compromised, or shared. Sensitive data may require encryption, Keychain storage, access controls, minimization, and a documented retention policy.

Illustrative Case: A Study Planner

Background and problem

Consider a fictional certificate-level project: StudyPlan, an iOS application that lets learners create courses, tasks, and study sessions. The first prototype stores tasks in an array. On relaunch, tasks disappear; duplicate imports occur; and deleting a course leaves orphaned tasks.

Analysis

The domain contains three entities:

  • Course: name and stable identifier;
  • Task: title, due date, completion state, and external identifier; and
  • StudySession: start time, duration, and the task studied.

A course owns many tasks, while each task belongs to one course. A task may have many study sessions, while each session belongs to one task. Imported tasks require a unique external identifier. Study sessions should be retained for progress reporting, so deleting a task should either be denied or replaced by an archival policy rather than blindly cascading.

Approach

The team creates SwiftData models with required names, dates, and identifiers; uses a unique constraint for imported task IDs; defines inverse relationships; and chooses cascade only for child records whose lifetime is genuinely dependent on the parent. The list screen uses a query that filters incomplete tasks and sorts by due date. A background context imports server records and saves them in one unit of work.

Before release, the team tests empty stores, duplicate imports, deleted courses, interrupted saves, background imports, and migration from the prototype schema. They also decide that a completed task is archived rather than deleted because learners need historical progress.

Outcome and learning points

The application now restores data after relaunch, updates imported tasks instead of duplicating them, and presents predictable deletion behavior. The central lesson is that persistence design begins with domain rules. Code syntax is important, but cardinality, ownership, identity, and lifecycle decisions determine whether the resulting application remains trustworthy.

Best Practices and Common Errors

  • Model domain facts, not screen-specific formatting.
  • Give every important relationship an intentional inverse and delete rule.
  • Prefer required attributes when the domain requires a value; avoid unnecessary optionals.
  • Use stable external identifiers for synchronization.
  • Enforce uniqueness in the model or store, not only through a preliminary fetch.
  • Fetch only the records needed by the current operation.
  • Keep UI and background context responsibilities clear.
  • Save at deliberate transaction boundaries and surface errors.
  • Test migration with old stores and realistic data.
  • Avoid modifying the persistent store directly with SQL unless the architecture explicitly supports it.
  • Treat deletion, privacy, backups, and retention as product decisions as well as technical decisions.

Contemporary Developments

SwiftData reflects a broader movement toward type-safe, declarative persistence integrated with SwiftUI and Swift concurrency. Core Data remains significant because of its maturity, tooling, large installed base, and advanced configuration options. Applications may also synchronize local data with CloudKit or web APIs, but synchronization introduces conflict resolution, identity mapping, network failure, and privacy concerns. AI-generated model code can accelerate scaffolding, yet generated code must be reviewed for incorrect relationships, unsafe deletion rules, missing migration plans, and accidental exposure of sensitive fields.

Chapter Summary

Data modeling defines the structure and behavior of persisted application information. Entities represent domain objects; attributes represent facts; relationships represent associations; cardinality, optionality, inverses, and delete rules express business constraints. SwiftData uses Swift model classes and macros, while Core Data uses managed object models, contexts, containers, and fetch requests.

A persistence context tracks object changes and provides a unit of work. Queries should filter, sort, and limit data appropriately. Updates require deliberate saves, and deletion must respect ownership and retention requirements. Stable identifiers and uniqueness constraints support synchronization, while indexes can improve frequently used queries at a write and storage cost. Schema changes require versioning, migration testing, and preservation of existing data. Reliable applications combine framework features with sound relational reasoning, concurrency discipline, privacy safeguards, and explicit domain rules.

Key Terms

Entity: A category of persisted objects.

Attribute: A value stored on an entity.

Relationship: A connection between persisted objects.

Cardinality: The permitted number of related objects.

Optionality: Whether a value or relationship may be absent.

Inverse relationship: The property representing the reverse direction of a relationship.

Delete rule: The behavior applied to related objects when an object is deleted.

Persistence context: A unit that tracks objects and pending changes.

Persistence container: The configured model, context, and store infrastructure.

Predicate: A Boolean condition used to filter records.

Fetch request: A description of records to retrieve.

Identity: The means by which an object is distinguished from others.

Uniqueness constraint: A rule preventing duplicate logical identity values.

Index: An auxiliary lookup structure that can accelerate queries.

Migration: The process of adapting stored data to a changed model.

Lightweight migration: Framework-inferred migration for compatible model changes.

Cascade: A delete rule that removes dependent related objects.

Soft delete: Marking a record as deleted instead of physically removing it.

Normalization: Structuring data to reduce unnecessary duplication and anomalies.

Managed object: A Core Data object monitored by a managed object context.

Review Questions

Short Answer Questions

  1. What is the role of a persistence context?
  2. Distinguish an attribute from a relationship.
  3. What is the purpose of an inverse relationship?
  4. Why might an application use a unique external identifier?
  5. State one risk of adding a required attribute during migration.

Long Answer Questions

  1. Explain how entities, attributes, relationships, cardinality, and optionality work together in a data model.
  2. Compare SwiftData and Core Data in terms of model definition, querying, and persistence infrastructure.
  3. Explain the differences among nullify, cascade, deny, and no-action delete rules.
  4. Describe a safe workflow for background persistence and context coordination.
  5. Discuss why migration testing must use existing stores rather than only fresh installations.

Analytical / Application Questions

  1. Design a model for a recipe application containing recipes, ingredients, and meal plans. State the cardinality, inverse relationships, and delete rules.
  2. An import process creates duplicate tasks whenever it runs twice. Analyze the likely modeling problem and propose a reliable correction.
  3. A production application must add a required currencyCode attribute to existing monetary records. Propose a migration and testing strategy, including treatment of records whose currency is unknown.

Multiple Choice Questions with Answers

Question 1

Which SwiftData annotation makes a class persistable?

A. @Model

B. @View

C. @State

D. @Published

Correct Answer: A. @Model

Question 2

What does a cascade delete rule do?

A. Prevents every deletion

B. Deletes dependent related objects

C. Converts all relationships to optional

D. Creates an index

Correct Answer: B. Deletes dependent related objects

Question 3

Which object tracks inserted, changed, and deleted Core Data objects?

A. NSPredicate

B. NSManagedObjectContext

C. NSSortDescriptor

D. NSPersistentStoreCoordinator only

Correct Answer: B. NSManagedObjectContext

Question 4

What is the primary purpose of a uniqueness constraint?

A. To sort records by date

B. To encrypt stored data

C. To prevent duplicate logical identity values

D. To delete archived records

Correct Answer: C. To prevent duplicate logical identity values

Question 5

Which approach is generally appropriate for a stable identifier received from a server?

A. Use it as a unique external identifier

B. Store it only in a display label

C. Regenerate it every time the app launches

D. Discard it after import

Correct Answer: A. Use it as a unique external identifier

Question 6

What is a predicate used for in a fetch?

A. Filtering records according to a condition

B. Creating a migration version

C. Assigning a delete rule

D. Opening a background thread automatically

Correct Answer: A. Filtering records according to a condition

Question 7

Why should managed objects not be passed casually between contexts?

A. They cannot contain attributes

B. They are associated with context and concurrency rules

C. They are always immutable

D. They can only be stored in arrays

Correct Answer: B. They are associated with context and concurrency rules

Question 8

Which change is most likely to require careful migration planning?

A. Changing a screen color

B. Adding a required attribute without a default

C. Renaming a local variable unused by the model

D. Changing a button label

Correct Answer: B. Adding a required attribute without a default

Question 9

What is a likely cost of adding an index?

A. Additional storage and write overhead

B. Automatic encryption of every field

C. Removal of all uniqueness constraints

D. Guaranteed faster performance for every query

Correct Answer: A. Additional storage and write overhead

Question 10

Why might a completed study task be archived instead of deleted?

A. Deletion always improves auditability

B. Historical progress may still be required

C. Archived objects cannot be queried

D. Archiving removes the need for a model

Correct Answer: B. Historical progress may still be required

APIs & Data Management
Chapter 9 of 14