SwiftData Relationships: One-to-Many, Many-to-Many, and Complex Queries


Your Pixar film tracker works great for a flat list — but the moment you need to know which characters appear in which films, or which studio produced which film, you have a relationship problem. SwiftData’s @Relationship macro makes this surprisingly elegant, but there are gotchas around delete rules and bidirectional inverse declarations that will crash your app if you don’t know about them.

This guide covers one-to-many and many-to-many relationships, all four delete rules, complex #Predicate queries with FetchDescriptor, and performance implications of lazy-loaded relationship graphs. We won’t cover CloudKit sync or multi-container setups — those are distinct topics.

Note: All SwiftData APIs require iOS 17+. Annotate your entry points with @available(iOS 17, *) or set your deployment target accordingly.

Contents

The Problem: Manual ID-Based Lookups

The SwiftData intro post showed you how to build a flat PixarFilm store. As soon as you need to associate characters with films, the naive approach is to store a film identifier on the character model and perform lookups yourself:

@Model
final class PixarFilm {
    var title: String
    var releaseYear: Int

    init(title: String, releaseYear: Int) {
        self.title = title
        self.releaseYear = releaseYear
    }
}

@Model
final class PixarCharacter {
    var name: String
    var filmTitle: String  // ← manual foreign key

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

This approach has three concrete problems:

  1. No referential integrity. Deleting a PixarFilm leaves orphaned PixarCharacter rows pointing to a title that no longer exists. The persistent store won’t catch this.
  2. No cascading behavior. You must manually clean up related objects before or after deletion.
  3. Inefficient queries. Fetching all characters for a film requires fetching all characters and filtering in memory, instead of letting the SQLite layer do the work.

SwiftData’s @Relationship macro solves all three issues at the schema level.

One-to-Many Relationships

A Pixar studio produces many films, but each film belongs to exactly one studio. That’s a classic one-to-many. Here’s how to model it:

import SwiftData

@available(iOS 17, *)
@Model
final class PixarStudio {
    var name: String

    @Relationship(deleteRule: .cascade, inverse: \PixarFilm.studio)
    var films: [PixarFilm] = []

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

@available(iOS 17, *)
@Model
final class PixarFilm {
    var title: String
    var releaseYear: Int
    var studio: PixarStudio?  // ← the "one" side

    init(title: String, releaseYear: Int) {
        self.title = title
        self.releaseYear = releaseYear
    }
}

A few things to unpack here:

  • @Relationship(deleteRule: .cascade, inverse: \PixarFilm.studio) on PixarStudio.films tells SwiftData two things: (a) when a studio is deleted, cascade that deletion to all its films; (b) the inverse relationship is PixarFilm.studio.
  • The studio property on PixarFilm is declared as PixarStudio? — optional because a film might temporarily exist without a studio assignment.
  • The inverse key path must point to a property on the other model that refers back to this model’s type. Getting this wrong is the most common source of SwiftData.SchemaError crashes at container initialization.

Warning: Both sides of a bidirectional relationship must be @Model classes. SwiftData does not support relationships to structs or enums.

To connect a film to a studio at runtime, assign either side — SwiftData keeps both ends in sync automatically:

let pixarStudio = PixarStudio(name: "Pixar Animation Studios")
let toyStory = PixarFilm(title: "Toy Story", releaseYear: 1995)

context.insert(pixarStudio)
context.insert(toyStory)

// Either assignment works — SwiftData syncs the inverse
toyStory.studio = pixarStudio
// pixarStudio.films now contains toyStory

Apple Docs: @Relationship — SwiftData

Delete Rules

The deleteRule parameter on @Relationship controls what happens to related objects when the owner is deleted. SwiftData supports four rules, mirroring Core Data’s behavior:

RuleBehavior
.cascadeDelete the owner → delete all related objects. Use for owned children (e.g., studio → films).
.nullifyDelete the owner → set the inverse to nil on related objects. Use when children survive without the parent.
.denyDelete the owner → throw an error if related objects exist. Enforces referential integrity.
.noActionDelete the owner → do nothing. Leaves the graph in a potentially inconsistent state.

The default is .nullify. The most common mistake is forgetting to set .cascade on owned children, leaving orphaned rows accumulating in the store over time.

Apple Docs: Schema.Relationship.DeleteRule — SwiftData

Many-to-Many Relationships

A Pixar film features many characters, and a character like Buzz Lightyear appears in multiple films. That’s a many-to-many. Unlike Core Data, which requires an explicit junction entity, SwiftData manages the junction table automatically:

@available(iOS 17, *)
@Model
final class PixarFilm {
    var title: String
    var releaseYear: Int
    var studio: PixarStudio?

    @Relationship(inverse: \PixarCharacter.films)
    var characters: [PixarCharacter] = []

    init(title: String, releaseYear: Int) {
        self.title = title
        self.releaseYear = releaseYear
    }
}

@available(iOS 17, *)
@Model
final class PixarCharacter {
    var name: String
    var voiceActor: String

    var films: [PixarFilm] = []  // ← no @Relationship needed on this side

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

Only one side needs the @Relationship macro with an inverse — typically the “more natural” owner. SwiftData creates a hidden join table in the SQLite store. You never interact with it directly.

Note: In a many-to-many with no explicit delete rule, deleting a film removes it from the junction table but does not delete its characters. If you want deletion of a film to delete characters that appear in no other films, you’ll need to implement that logic manually in a pre-delete hook.

Adding characters to a film is straightforward:

let buzz = PixarCharacter(name: "Buzz Lightyear", voiceActor: "Tim Allen")
let woody = PixarCharacter(name: "Woody", voiceActor: "Tom Hanks")
let toyStory = PixarFilm(title: "Toy Story", releaseYear: 1995)
let toyStory2 = PixarFilm(title: "Toy Story 2", releaseYear: 1999)

context.insert(buzz)
context.insert(woody)
context.insert(toyStory)
context.insert(toyStory2)

toyStory.characters = [buzz, woody]
toyStory2.characters = [buzz, woody]

// buzz.films is now [toyStory, toyStory2] — SwiftData maintains the inverse

Complex Queries with #Predicate

SwiftData’s #Predicate macro (from the Foundation framework) provides compile-time-checked predicates that replace Core Data’s stringly-typed NSPredicate. The macro expands your closure into a typed Predicate<Model> that SwiftData translates to SQL.

Here’s a compound predicate that fetches post-2000 films featuring a specific character:

@available(iOS 17, *)
func fetchFilmsWithWoody(in context: ModelContext) throws -> [PixarFilm] {
    let predicate = #Predicate<PixarFilm> { film in
        film.releaseYear >= 2000 &&
        film.characters.contains { $0.name == "Woody" }
    }

    let descriptor = FetchDescriptor<PixarFilm>(
        predicate: predicate,
        sortBy: [SortDescriptor(\.releaseYear, order: .reverse)]
    )

    return try context.fetch(descriptor)
}

The #Predicate macro validates key paths and value types at compile time. If you typo releaseYear or compare it to a String, the build fails immediately — a significant improvement over Core Data’s runtime predicate crashes.

A few nuances worth knowing:

  • Relationship traversal in predicates works one level deep reliably. Predicates that traverse two or more hops (e.g., film.studio.name == "Pixar") should work on iOS 17.4+ but had known issues in early iOS 17 betas. Test thoroughly.
  • contains on relationships compiles to a SQL subquery. It’s correct but can be expensive on large collections — see the Performance Considerations section.
  • Dynamic predicates require building them at runtime using #Predicate closures with captured variables. Avoid constructing NSPredicate objects and bridging — that bypasses type checking.

Apple Docs: #Predicate — Foundation | FetchDescriptor — SwiftData

Filtering Across Relationships

Sometimes you need the inverse: fetch all characters that appear in films from a specific studio. The same mechanism works, just from the character’s perspective:

@available(iOS 17, *)
func fetchCharactersFromPixar(in context: ModelContext) throws -> [PixarCharacter] {
    let predicate = #Predicate<PixarCharacter> { character in
        character.films.contains { $0.studio?.name == "Pixar Animation Studios" }
    }

    return try context.fetch(FetchDescriptor<PixarCharacter>(predicate: predicate))
}

FetchDescriptor and Pagination

FetchDescriptor gives you precise control over what gets loaded from the persistent store. Beyond predicates and sort descriptors, it supports pagination — critical for large datasets:

@available(iOS 17, *)
func fetchFilmsPage(
    page: Int,
    pageSize: Int = 20,
    in context: ModelContext
) throws -> [PixarFilm] {
    var descriptor = FetchDescriptor<PixarFilm>(
        sortBy: [SortDescriptor(\.releaseYear, order: .reverse)]
    )
    descriptor.fetchLimit = pageSize
    descriptor.fetchOffset = page * pageSize

    return try context.fetch(descriptor)
}

fetchLimit and fetchOffset map directly to SQL LIMIT and OFFSET, keeping memory usage flat regardless of total row count. This is the right approach for any list view that could grow to hundreds of records.

You can also request only the count without materializing objects:

@available(iOS 17, *)
func filmCount(in context: ModelContext) throws -> Int {
    let descriptor = FetchDescriptor<PixarFilm>()
    return try context.fetchCount(descriptor)
}

Tip: fetchCount issues a SELECT COUNT(*) query — far cheaper than fetching all objects just to call .count.

@Query with Compound Predicates in SwiftUI

The @Query property wrapper integrates SwiftData fetches directly into SwiftUI views. It reacts to store changes and drives view updates automatically. For compound predicates, build the descriptor before constructing the query:

@available(iOS 17, *)
struct FilmsByStudioView: View {
    @Query private var films: [PixarFilm]

    init(studioName: String) {
        let predicate = #Predicate<PixarFilm> { film in
            film.studio?.name == studioName
        }
        _films = Query(
            FetchDescriptor(
                predicate: predicate,
                sortBy: [SortDescriptor(\.releaseYear)]
            )
        )
    }

    var body: some View {
        List(films) { film in
            Text("\(film.title) (\(film.releaseYear))")
        }
    }
}

Initializing _films (the wrapped storage, not the projected value) inside init lets you pass runtime values into a static #Predicate. This is the standard pattern for dynamic @Query parameters — you’ll see it in Apple’s own sample code.

Warning: @Query runs on the @MainActor and uses the view’s model context automatically. Do not share a background ModelContext with a @Query — the change notifications won’t fire correctly.

Advanced Usage

Unique Constraints

SwiftData’s @Attribute(.unique) annotation enforces uniqueness at the schema level. This is particularly useful for canonical identifiers:

@available(iOS 17, *)
@Model
final class PixarFilm {
    @Attribute(.unique) var imdbID: String  // ← unique constraint
    var title: String
    var releaseYear: Int

    init(imdbID: String, title: String, releaseYear: Int) {
        self.imdbID = imdbID
        self.title = title
        self.releaseYear = releaseYear
    }
}

When you insert a model with a duplicate imdbID, SwiftData performs an upsert — it updates the existing record rather than creating a duplicate. This is the SwiftData equivalent of Core Data’s NSMergeByPropertyObjectTrumpMergePolicy.

Batch Operations with ModelContext

For large-scale modifications — say, migrating all films from one studio to another — use batch deletes and bulk inserts rather than iterating:

@available(iOS 17, *)
func deleteAllFilms(in context: ModelContext) throws {
    try context.delete(model: PixarFilm.self)  // ← batch delete
    try context.save()
}

ModelContext.delete(model:where:) translates to a SQL DELETE statement and bypasses in-memory object graphs, making it significantly faster for bulk operations. Note that it does not fire relationship cascade rules in-memory — the store handles them at the SQL layer.

Faulting and Lazy Loading

SwiftData lazily loads relationship objects, similar to Core Data faulting. When you fetch a PixarStudio, its films array is a fault — a placeholder that triggers a database round-trip when first accessed. This is usually correct behavior but can cause performance surprises in tight loops:

// ❌ N+1 problem: each studio.films access triggers a separate query
for studio in studios {
    print(studio.films.count)  // ← fault fires here
}

// ✅ Use a single query that joins the relationship
let descriptor = FetchDescriptor<PixarStudio>()
// Access films only when needed, or prefetch with a predicate

For views that need relationship data immediately (e.g., a detail screen), access the relationship property during the same ModelContext transaction as the initial fetch to avoid UI stutter.

Performance Considerations

Relationship traversal in predicates compiles to SQL JOINs and subqueries. For well-indexed models with thousands of rows, this is fast. The performance problems emerge at scale in three specific patterns:

Deep traversal: Predicates like character.films.contains { $0.studio?.country == "USA" } cross two joins. Profile with Instruments’ Core Data template (which also covers SwiftData) — look for high persistentStoreRequest durations.

Unbounded contains on large many-to-many collections: If a PixarCharacter appears in 50 films, film.characters.contains { ... } scans the junction table for each candidate film. Consider caching computed properties (denormalizing) for hot query paths.

Repeated fault firing in list cells: Every cell that accesses a relationship property fires a fault if the relationship hasn’t been loaded. Batch-prefetch relationship data before populating the list rather than letting each cell pull lazily.

SwiftData does not yet expose fine-grained prefetching APIs equivalent to Core Data’s NSFetchRequest.relationshipKeyPathsForPrefetching. The workaround is to construct explicit FetchDescriptor queries that join relationship data via predicates, materializing the graph you need in one round-trip.

Apple Docs: Monitoring Your App’s Memory Usage with Instruments — Xcode

When to Use (and When Not To)

ScenarioRecommendation
Owned parent-child data (studio → films).cascade delete rule — let SwiftData manage the lifecycle
Shared entities that survive parent deletionMany-to-many with .nullify or no explicit delete rule
Enforcing referential integrity at insert time.deny delete rule — fail loudly rather than silently orphan
Hot read path with thousands of related objectsDenormalize into a computed column or use an aggregate query
Temporary association (e.g., a draft link you might undo)Keep in-memory and only persist on confirm
CloudKit-backed storeAvoid optional-to-optional relationships — CloudKit requires one non-optional side

Summary

  • @Relationship replaces manual foreign-key bookkeeping with compile-time-verified, store-managed graph edges.
  • The inverse parameter on @Relationship must be set correctly — a mismatched inverse is the most common source of SchemaError at container initialization.
  • Delete rules (.cascade, .nullify, .deny, .noAction) encode your data integrity policy directly in the schema, not in ad-hoc application code.
  • #Predicate gives you compile-time-checked, type-safe queries. Relationship traversal in predicates compiles to SQL JOINs and subqueries.
  • FetchDescriptor with fetchLimit/fetchOffset is the correct tool for pagination — maps directly to SQL LIMIT/OFFSET.
  • SwiftData lazily loads relationship objects. Avoid repeated fault firing in tight loops — batch-prefetch before iterating.

Now that your data model handles relationships correctly, the next challenge is shipping schema changes without wiping user data. SwiftData Schema Migrations covers VersionedSchema, SchemaMigrationPlan, and multi-step migration strategies.