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
- Defining a Model Hierarchy
- Querying with Type-Filtering Predicates
- Optimizing Fetches with propertiesToFetch
- Advanced Usage
- Performance Considerations
- When to Use (and When Not To)
- Summary
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
@Modelclass. PassingFeatureFilm.selforShort.selfindividually 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
@Querywith 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
propertiesToFetchwill 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 yourpropertiesToFetchselection 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.modelsarray. 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 shape | Row count | Recommendation |
|---|---|---|
| 2-3 subclasses, <10 unique properties | Any | STI works well |
| 4+ subclasses, many unique properties | < 100K rows | STI is fine. Monitor table size |
| 4+ subclasses, many unique properties | > 100K rows | Separate 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)
| Scenario | Recommendation |
|---|---|
| Types share 70%+ of properties | Use inheritance. This is the sweet spot |
| You need polymorphic queries | Use inheritance. The is predicate makes this trivial |
| Subtypes are completely different models | Use separate @Model classes instead |
| You need to support iOS 18 or earlier | Use the enum discriminator pattern |
| 6+ subtypes with divergent schemas | Consider a protocol-based approach |
| Migrating from Core Data entity inheritance | Use 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
@Modelclass 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. propertiesToFetchonFetchDescriptorlets 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.