SwiftData Class Inheritance: Model Hierarchies and Type Filtering


You have a Trip model. Some trips are personal, some are business. They share 90% of their properties, but each subtype carries its own unique data. Before iOS 26, you had two choices: a bloated flat model full of optional fields, or a manual “type” enum with switch statements scattered across your codebase. Neither option was great.

iOS 26 finally brings class inheritance to SwiftData. This post covers how to define @Model hierarchies, query them with type-filtering predicates, and optimize fetch performance with propertiesToFetch — the three pieces you need to use this feature in production.

Contents

The Problem

Imagine you are building a Pixar studio management app. Every production — whether it is a feature film, a short, or a TV special — shares common properties like title, director, and releaseDate. But a feature film has a boxOfficeGross, a short has a festivalSubmissions list, and a TV special has an episodeCount. Before iOS 26, the standard approach looked like this:

@Model
final class Production {
    var title: String
    var director: String
    var releaseDate: Date

    // Feature-film-only
    var boxOfficeGross: Decimal?
    // Short-only
    var festivalSubmissions: [String]?
    // TV-special-only
    var episodeCount: Int?

    // A manual discriminator
    var productionType: String // "feature", "short", "tvSpecial"

    init(title: String, director: String, releaseDate: Date,
         productionType: String) {
        self.title = title
        self.director = director
        self.releaseDate = releaseDate
        self.productionType = productionType
    }
}

This is the “wide table” pattern, and it falls apart quickly. Every consumer of Production must remember which optionals are valid for which type. Filtering by type means comparing raw strings. The compiler cannot help you if you set boxOfficeGross on a short film — it is just nil by convention, not by contract. In a codebase with dozens of model types, this approach creates a maintenance burden that grows linearly with every new variant.

Defining a Model Hierarchy

With iOS 26, SwiftData supports @Model class inheritance. The base class defines shared properties; subclasses add their own stored properties, and SwiftData handles the underlying storage schema automatically.

import SwiftData

@Model
class Production {
    var title: String
    var director: String
    var releaseDate: Date

    init(title: String, director: String, releaseDate: Date) {
        self.title = title
        self.director = director
        self.releaseDate = releaseDate
    }
}

Each subclass inherits from Production and adds its own unique properties. You do not need to repeat the @Model macro on subclasses — the hierarchy is inferred from the base class declaration.

final class FeatureFilm: Production {
    var boxOfficeGross: Decimal
    var runtime: Int // minutes

    init(title: String, director: String, releaseDate: Date,
         boxOfficeGross: Decimal, runtime: Int) {
        self.boxOfficeGross = boxOfficeGross
        self.runtime = runtime
        super.init(title: title, director: director,
                   releaseDate: releaseDate)
    }
}

final class Short: Production {
    var festivalSubmissions: [String]
    var runtime: Int // minutes

    init(title: String, director: String, releaseDate: Date,
         festivalSubmissions: [String], runtime: Int) {
        self.festivalSubmissions = festivalSubmissions
        self.runtime = runtime
        super.init(title: title, director: director,
                   releaseDate: releaseDate)
    }
}

final class TVSpecial: Production {
    var episodeCount: Int
    var network: String

    init(title: String, director: String, releaseDate: Date,
         episodeCount: Int, network: String) {
        self.episodeCount = episodeCount
        self.network = network
        super.init(title: title, director: director,
                   releaseDate: releaseDate)
    }
}

Register the full hierarchy in your ModelContainer by specifying the base class. SwiftData discovers the subclasses automatically:

@main
struct PixarStudioApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: Production.self)
    }
}

Note: You only register the root @Model class. Passing FeatureFilm.self or Short.self individually is unnecessary and will cause a runtime error if the base class is omitted.

Creating Instances

Inserting data works exactly as you would expect. The ModelContext stores each instance with its correct concrete type:

func seedSampleData(context: ModelContext) {
    let toyStory = FeatureFilm(
        title: "Toy Story", director: "John Lasseter",
        releaseDate: Date(timeIntervalSince1970: 816_998_400),
        boxOfficeGross: 373_554_033, runtime: 81
    )

    let forTheBirds = Short(
        title: "For the Birds",
        director: "Ralph Eggleston",
        releaseDate: Date(timeIntervalSince1970: 992_390_400),
        festivalSubmissions: ["Annecy", "SIGGRAPH"],
        runtime: 3
    )

    context.insert(toyStory)
    context.insert(forTheBirds)
}

Under the hood, SwiftData uses a single-table inheritance strategy backed by Core Data’s entity inheritance. All properties — base and subclass — live in one SQLite table. A discriminator column tracks the concrete type. This mirrors how Core Data has handled entity inheritance for years, but now with a Swift-native API.

Querying with Type-Filtering Predicates

The most powerful aspect of model hierarchies is querying. You can fetch the base class to get all productions, or narrow your query to a specific subclass. The #Predicate macro gained is type-checking support in iOS 26:

Fetching All Productions

A query on the base class returns every instance in the hierarchy, regardless of concrete type:

@Query(sort: \Production.releaseDate, order: .reverse)
private var allProductions: [Production]

Each element in allProductions retains its concrete type. You can use as? to downcast when you need subclass-specific properties:

for production in allProductions {
    if let film = production as? FeatureFilm {
        print("\(film.title) grossed $\(film.boxOfficeGross)")
    }
}

Filtering by Subclass Type

To fetch only feature films, use the is keyword inside #Predicate:

let featureFilmDescriptor = FetchDescriptor<Production>(
    predicate: #Predicate { $0 is FeatureFilm },
    sortBy: [SortDescriptor(\.releaseDate, order: .reverse)]
)

let films = try context.fetch(featureFilmDescriptor)

This compiles down to a SQL WHERE clause on the discriminator column — it does not fetch all rows and filter in memory. You can combine type checks with property predicates for precise queries:

let recentBlockbusters = FetchDescriptor<Production>(
    predicate: #Predicate {
        $0 is FeatureFilm && $0.releaseDate > cutoffDate
    }
)

Tip: You can also use @Query with the type predicate directly in SwiftUI. The #Predicate { $0 is FeatureFilm } syntax works anywhere you would use a standard predicate.

Querying Subclass-Specific Properties

When you know you only want a specific subclass, you can query the subclass type directly to get full access to its properties in the predicate:

let descriptor = FetchDescriptor<FeatureFilm>(
    predicate: #Predicate { $0.boxOfficeGross > 500_000_000 },
    sortBy: [SortDescriptor(\.boxOfficeGross, order: .reverse)]
)

let blockbusters = try context.fetch(descriptor)
// blockbusters is [FeatureFilm] — no downcasting needed

This is the cleaner pattern when you know the concrete type upfront. Reserve the is filtering approach for when you need polymorphic queries across the hierarchy.

Optimizing Fetches with propertiesToFetch

iOS 26 also introduces propertiesToFetch on FetchDescriptor, letting you request only the columns you need. This is particularly valuable with inheritance hierarchies, where the underlying table includes columns from every subclass.

var listDescriptor = FetchDescriptor<Production>(
    sortBy: [SortDescriptor(\.releaseDate, order: .reverse)]
)
listDescriptor.propertiesToFetch = [
    \.title, \.director, \.releaseDate
]

When displaying a list of all productions, you likely only need the shared base properties. By specifying propertiesToFetch, SwiftData generates a SQL SELECT with only those columns, reducing I/O and memory usage.

Warning: Accessing a property that was not included in propertiesToFetch will trigger a fault — SwiftData will issue an additional database round-trip to load the missing data. This is the same faulting behavior Core Data developers are familiar with. If you find yourself faulting frequently, reconsider your propertiesToFetch selection or drop the optimization for that query.

Combine propertiesToFetch with type filtering for maximum precision:

var filmListDescriptor = FetchDescriptor<FeatureFilm>(
    predicate: #Predicate { $0.boxOfficeGross > 100_000_000 },
    sortBy: [SortDescriptor(\.releaseDate, order: .reverse)]
)
filmListDescriptor.propertiesToFetch = [\.title, \.boxOfficeGross]

This fetches only the title and box office gross of feature films that crossed $100M — no other columns, no other subclass rows.

Advanced Usage

Multi-Level Hierarchies

SwiftData supports more than one level of inheritance. You can model a deeper hierarchy when your domain calls for it:

@Model
class Production {
    var title: String
    var director: String
    var releaseDate: Date
    // ...
}

class TheatricalRelease: Production {
    var distributor: String
    // ...
}

final class FeatureFilm: TheatricalRelease {
    var boxOfficeGross: Decimal
    // ...
}

final class ShortFilm: TheatricalRelease {
    var festivalSubmissions: [String]
    // ...
}

A query on TheatricalRelease returns both FeatureFilm and ShortFilm instances but excludes any direct Production subclasses that are not theatrical releases. The type predicate $0 is TheatricalRelease works at every level in the hierarchy.

Warning: Deep hierarchies (three or more levels) inflate the table width significantly because every property at every level maps to the same table. Keep hierarchies shallow — two levels is the sweet spot for most apps.

Relationships Across the Hierarchy

Relationships defined on the base class are inherited by all subclasses. You can also define relationships on subclasses for type-specific associations:

@Model
class Production {
    var title: String
    @Relationship(deleteRule: .cascade)
    var crew: [CrewMember]
    // ...
}

final class FeatureFilm: Production {
    var boxOfficeGross: Decimal
    @Relationship(deleteRule: .nullify)
    var sequel: FeatureFilm? // Self-referential, subclass-only
    // ...
}

When you delete a FeatureFilm, the cascade rule from the base class applies to crew, and the nullify rule applies to sequel. Each level owns its own relationship semantics, and SwiftData resolves them correctly.

Apple Docs: @Relationship — SwiftData

Schema Migrations with Inheritance

Adding a new subclass to an existing hierarchy is a lightweight migration — SwiftData adds columns to the existing table and registers a new discriminator value. No custom SchemaMigrationPlan is needed for additive changes.

However, removing a subclass or moving properties between levels in the hierarchy requires a custom migration stage. Treat this the same way you would treat removing an entity in Core Data: plan the migration explicitly in your schema migration plan.

enum ProductionSchemaV2: VersionedSchema {
    static var versionIdentifier: Schema.Version =
        .init(2, 0, 0)
    static var models: [any PersistentModel.Type] {
        [Production.self, FeatureFilm.self,
         Short.self, TVSpecial.self]
    }
}

Tip: Always include every class in the hierarchy in your VersionedSchema.models array. Omitting a subclass will cause SwiftData to silently drop its data during migration.

Performance Considerations

SwiftData’s inheritance uses single-table inheritance (STI) under the hood. This has well-understood performance characteristics:

Reads are fast. Querying any type in the hierarchy hits a single table. No joins. The is predicate translates to a simple WHERE clause on the discriminator column, which SQLite handles efficiently with an index.

Storage is wider than necessary. Every row includes columns for every subclass, most of which are NULL for any given row. For hierarchies with many subclasses or large subclass-specific payloads, this means wasted disk space and larger page loads.

Write contention increases. Because all types share one table, concurrent writes to different subclass types contend on the same SQLite table lock. In apps with heavy background writes, this can become a bottleneck.

Here is a practical guideline for sizing decisions:

Hierarchy shapeRow countRecommendation
2-3 subclasses, <10 unique propertiesAnySTI works well
4+ subclasses, many unique properties< 100K rowsSTI is fine. Monitor table size
4+ subclasses, many unique properties> 100K rowsSeparate models with a protocol

Use propertiesToFetch aggressively on list views. Profile with Instruments’ Core Data template — SwiftData’s queries appear there because the underlying store is still SQLite via Core Data.

Apple Docs: FetchDescriptor — SwiftData

When to Use (and When Not To)

ScenarioRecommendation
Types share 70%+ of propertiesUse inheritance. This is the sweet spot
You need polymorphic queriesUse inheritance. The is predicate makes this trivial
Subtypes are completely different modelsUse separate @Model classes instead
You need to support iOS 18 or earlierUse the enum discriminator pattern
6+ subtypes with divergent schemasConsider a protocol-based approach
Migrating from Core Data entity inheritanceUse inheritance. The storage model is identical

For teams migrating from Core Data, SwiftData inheritance is a direct conceptual replacement for NSManagedObject subclass hierarchies. The underlying SQLite schema is compatible — in many cases, the same database file works with both frameworks if you keep the entity names aligned.

Summary

  • SwiftData in iOS 26 supports @Model class inheritance with automatic subclass discovery, single-table storage, and zero boilerplate for the discriminator column.
  • The #Predicate { $0 is Subclass } syntax enables type-filtered queries that compile to efficient SQL — no in-memory filtering required.
  • propertiesToFetch on FetchDescriptor lets you select only the columns you need, reducing I/O in list views built on wide inheritance tables.
  • Single-table inheritance trades storage width for query simplicity. Keep hierarchies shallow (two levels) and monitor table size as your subclass count grows.
  • Adding subclasses is a lightweight migration; removing them requires a custom SchemaMigrationPlan.

SwiftData inheritance solves the polymorphic modeling problem that has plagued Swift persistence since SwiftData launched. If your data has a natural “is-a” relationship, this is now the right tool. For more on how SwiftData tracks changes across contexts — especially relevant when inheritance adds complexity to your sync layer — see SwiftData Persistent History and Change Tracking.