SwiftData skill for Codex

Persistence that survives every app update. Codex designs SwiftData models with explicit delete rules, raw-value enums and indexes, filters in the store with #Predicate instead of in Swift, and moves bulk imports to a @ModelActor. Every shipped schema is frozen as a VersionedSchema, and each change gets a lightweight or custom stage that is proven by opening a store written by the previous version.

  • Mobile & Desktop
  • $swiftdata
  • 221 lines
  • 3 files
  • ~2,050 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- swiftdata

Installs to ~/.agents/skills/swiftdata/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $swiftdata, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Models and persists data with SwiftData on iOS, iPadOS and macOS - @Model classes, @Attribute and @Relationship with delete rules, #Unique and #Index, @Query with #Predicate and SortDescriptor, FetchDescriptor paging, background imports with @ModelActor, CloudKit sync constraints, and versioned schema migrations with SchemaMigrationPlan. Use when adding persistence to a SwiftUI app, designing models and relationships, writing queries that filter or sort, importing large data sets off the main thread, shipping a schema change without losing user data, or moving from Core Data.

Use it when

  • Adding local persistence to a SwiftUI app
  • Writing @Query filters, sorting or paging
  • Changing a model that has already shipped to users
  • Bulk imports block the main thread

Use something else when

How to check the result

An automated test opens a store created with each earlier schema version, runs the migration plan and asserts record counts and key fields, and an over-the-top install of the previous build keeps its data.

The mistake it prevents

Changing a shipped @Model without a migration plan, then 'fixing' the launch crash by deleting the store and wiping every user's data.

~/.agents/skills/swiftdata/SKILL.md

--- name: swiftdata description: Models and persists data with SwiftData on iOS, iPadOS and macOS - @Model classes, @Attribute and @Relationship with delete rules, #Unique and… license: MIT ---

Done well means: every shipped schema is captured as a VersionedSchema, each release’s changes migrate existing stores on a real upgrade path without data loss, queries filter in the store (not in Swift after fetching everything), and heavy writes happen off the main actor.

Inspect first

cat AGENTS.md 2>/dev/null
grep -rnE '@Model|ModelContainer|VersionedSchema|SchemaMigrationPlan|NSPersistentContainer' \
  --include='*.swift' . | head -40
grep -rn 'IPHONEOS_DEPLOYMENT_TARGET' *.xcodeproj/project.pbxproj | sort -u
grep -rn 'iCloud\|CloudKit' *.entitlements 2>/dev/null

Answer three questions before writing code: has any version of this schema shipped to users (then migrations are mandatory), is CloudKit sync on (then the model rules below apply), and what is the deployment target (#Unique, #Index and history need iOS 18; model inheritance needs iOS 26).

Models

import SwiftData

@Model
final class Trip {
    #Unique<Trip>([\.remoteID])
    #Index<Trip>([\.startDate], [\.name])

    var remoteID: String
    var name: String
    var startDate: Date
    var statusRaw: String = TripStatus.planned.rawValue      // store enums as raw values
    @Attribute(.externalStorage) var coverImage: Data?
    @Relationship(deleteRule: .cascade, inverse: \Stop.trip) var stops: [Stop] = []
    @Transient var isSelected = false

    var status: TripStatus {
        get { TripStatus(rawValue: statusRaw) ?? .planned }
        set { statusRaw = newValue.rawValue }
    }

    init(remoteID: String, name: String, startDate: Date) {
        self.remoteID = remoteID
        self.name = name
        self.startDate = startDate
    }
}

@Model
final class Stop {
    var title: String
    var trip: Trip?
    init(title: String) { self.title = title }
}
  • Delete rules: .cascade for owned children, .nullify (default) for references, .deny to block deleting a parent with children.
  • Declare the inverse on one side only; SwiftData links the other.
  • .externalStorage for blobs over roughly 100 KB so rows stay small.
  • Predicates cannot reliably compare enum cases or computed properties: persist raw values and query those.
  • #Unique makes inserts with a duplicate key update the existing row (upsert). Before iOS 18 use @Attribute(.unique) on a single property.

Container and contexts

@main
struct TripsApp: App {
    let container: ModelContainer = {
        let schema = Schema(versionedSchema: SchemaV2.self)
        let config = ModelConfiguration(schema: schema)
        do {
            return try ModelContainer(for: schema, migrationPlan: TripsMigrationPlan.self,
                                      configurations: config)
        } catch {
            fatalError("Store failed to open: \(error)")       // log and surface in real apps
        }
    }()

    var body: some Scene {
        WindowGroup { ContentView() }.modelContainer(container)
    }
}
  • @Environment(\.modelContext) in views gives the main context (autosaves on UI events). Call try context.save() explicitly after important writes rather than relying on autosave timing.
  • Previews and tests: ModelConfiguration(isStoredInMemoryOnly: true).

Queries

struct UpcomingTrips: View {
    @Query private var trips: [Trip]

    init(after date: Date) {
        _trips = Query(
            filter: #Predicate<Trip> { $0.startDate >= date && $0.statusRaw != "cancelled" },
            sort: [SortDescriptor(\.startDate)]
        )
    }

    var body: some View {
        List(trips) { Text($0.name) }
    }
}

Dynamic filters (search text, toggles) go through the view’s init like above; the parent passes the changing value. Outside views:

var descriptor = FetchDescriptor<Trip>(
    predicate: #Predicate { $0.name.localizedStandardContains(search) },
    sortBy: [SortDescriptor(\.startDate, order: .reverse)]
)
descriptor.fetchLimit = 50
descriptor.fetchOffset = page * 50
descriptor.propertiesToFetch = [\.name, \.startDate]
let trips = try context.fetch(descriptor)
let total = try context.fetchCount(FetchDescriptor<Trip>())
  • Capture external values in local constants before the predicate; predicates cannot call arbitrary methods or reference self.
  • Optional relationships: compare through optional chaining ($0.trip?.name == name). If the macro rejects the expression or it fails at runtime, try $0.trip.flatMap { $0.name == name } == true or query from the other side of the relationship. Test every non-trivial predicate against a real store; some compile and still throw when executed.
  • Bulk delete: try context.delete(model: Trip.self, where: #Predicate { $0.statusRaw == "cancelled" }).

Background work

@ModelActor
actor TripImporter {
    func importTrips(_ dtos: [TripDTO]) throws {
        for (i, dto) in dtos.enumerated() {
            modelContext.insert(Trip(remoteID: dto.id, name: dto.name, startDate: dto.start))
            if i % 500 == 0 { try modelContext.save() }
        }
        try modelContext.save()
    }
}

let importer = TripImporter(modelContainer: container)
try await importer.importTrips(dtos)

Model objects are not Sendable. Pass PersistentIdentifiers between actors and re-fetch with modelContext.model(for: id) (or registeredModel(for:)) on the other side.

Schema changes and migrations

Rule: the first time you ship, wrap the schema in SchemaV1: VersionedSchema. Every later change adds a new version and a stage in the plan. Lightweight stages cover added properties with defaults, added optional properties, renames with @Attribute(originalName:), and new models; anything that transforms or de-duplicates data needs a custom stage.

For full versioned-schema code, custom stages, de-duplication before adding #Unique, and how to test an upgrade from an old store, read references/migrations.md whenever a model property, type, uniqueness rule or relationship changes in a schema that has shipped.

CloudKit sync rules

If the container syncs with CloudKit (ModelConfiguration(cloudKitDatabase: .automatic) or iCloud entitlement present):

  • No #Unique / .unique attributes.
  • Every property optional or with a default value; every relationship optional.
  • No .deny delete rule.
  • Schema changes deployed to the production CloudKit container are additive only. Deploy the schema in the CloudKit Console before release; that step affects all users, so confirm it with the user.

Gotchas

  • Changing a model without a migration plan on a shipped app makes the store fail to open; the common “fix” of deleting the store destroys user data. Never do that without explicit user confirmation and a stated reason.
  • @Query re-runs when any matching model changes; very large unbounded queries in frequently rendered views hurt. Add fetchLimit via Query(descriptor).
  • Inserting a child and setting parent.children.append(child) before inserting the parent can crash or duplicate; insert parents first, then relate.
  • Autosave hides errors. Call save() in a do/catch where failures matter.
  • Default values in init vs property declarations: migrations use the property declaration default, so give new non-optional properties a declared default.

Verification

  • Unit tests with an in-memory container for queries and delete rules.
  • Upgrade test: keep a store file created by the previous release (or create one with the previous schema version in a test), open it with the new container and plan, and assert counts and key values (see the migrations reference).
  • xcodebuild test -scheme App -destination 'platform=iOS Simulator,name=iPhone 17' -derivedDataPath build/DerivedData
  • Install the previous App Store or TestFlight build on a simulator, add data, then install the new build over it and confirm the data survives.

Report template

**Schema:** V2 -> V3 (Trip.notes added, Stop.order renamed from position)
**Migration:** lightweight V2->V3; upgrade test from V1 and V2 fixtures passes
**CloudKit:** off
**Queries changed:** UpcomingTrips filters in store, fetchLimit 200
**Checks:** 18 persistence tests green; manual upgrade from TestFlight 3.1 build kept 240 trips

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "SwiftData"
  short_description: "Persistence that survives every app update"
  default_prompt: "$swiftdata Add a notes field and rename start to startDate on Trip without losing user data"
references/migrations.md157 lines, 6.0 KB
# SwiftData migrations in depth

Read this when a shipped schema changes. The goal: a user on any previously released
version can install the new build and keep every record.

## 1. Freeze each shipped schema as a version

Move model definitions into versioned namespaces. The app's current types become
typealiases to the latest version.

```swift
import SwiftData

enum SchemaV1: VersionedSchema {
    static let versionIdentifier = Schema.Version(1, 0, 0)
    static var models: [any PersistentModel.Type] { [Trip.self] }

    @Model
    final class Trip {
        var name: String
        var start: Date
        init(name: String, start: Date) { self.name = name; self.start = start }
    }
}

enum SchemaV2: VersionedSchema {
    static let versionIdentifier = Schema.Version(2, 0, 0)
    static var models: [any PersistentModel.Type] { [Trip.self] }

    @Model
    final class Trip {
        var name: String
        @Attribute(originalName: "start") var startDate: Date    // rename: lightweight
        var notes: String = ""                                   // added with default: lightweight
        init(name: String, startDate: Date) { self.name = name; self.startDate = startDate }
    }
}

enum SchemaV3: VersionedSchema {
    static let versionIdentifier = Schema.Version(3, 0, 0)
    static var models: [any PersistentModel.Type] { [Trip.self] }

    @Model
    final class Trip {
        #Unique<Trip>([\.name, \.startDate])                      // needs a de-dup first: custom
        var name: String
        var startDate: Date
        var notes: String = ""
        init(name: String, startDate: Date) { self.name = name; self.startDate = startDate }
    }
}

typealias Trip = SchemaV3.Trip
```

Never edit a version that has shipped. Copy it to a new version and change the copy.

## 2. The plan

```swift
enum TripsMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] { [SchemaV1.self, SchemaV2.self, SchemaV3.self] }
    static var stages: [MigrationStage] { [v1ToV2, v2ToV3] }

    static let v1ToV2 = MigrationStage.lightweight(fromVersion: SchemaV1.self, toVersion: SchemaV2.self)

    static let v2ToV3 = MigrationStage.custom(
        fromVersion: SchemaV2.self,
        toVersion: SchemaV3.self,
        willMigrate: { context in
            // Runs against the OLD schema: remove duplicates the new #Unique would reject.
            let trips = try context.fetch(FetchDescriptor<SchemaV2.Trip>(
                sortBy: [SortDescriptor(\.name), SortDescriptor(\.startDate)]))
            var seen = Set<String>()
            for trip in trips {
                let key = "\(trip.name)|\(trip.startDate.timeIntervalSince1970)"
                if !seen.insert(key).inserted { context.delete(trip) }
            }
            try context.save()
        },
        didMigrate: nil      // runs against the NEW schema: backfill derived fields here
    )
}
```

Stages run in order from the store's version to the latest, so a V1 user goes through
both stages.

## 3. Which stage type

| Change | Stage |
|---|---|
| Add optional property, or non-optional with a declared default | lightweight |
| Add a new model | lightweight |
| Rename property (`@Attribute(originalName:)`) | lightweight |
| Remove property | lightweight (data is dropped: confirm that is intended) |
| Change a property's type (String -> Int, Date -> String) | custom: add new property, copy in `didMigrate`, drop old in a later version |
| Add uniqueness on existing data | custom: de-duplicate in `willMigrate` |
| Split one model into two, or move a property across a relationship | custom, often two versions |
| Change delete rule | lightweight, but verify behaviour in tests |

Type changes are safest as two releases: V(n) adds the new property and copies values;
V(n+1) removes the old one.

## 4. Testing the upgrade

Create a store with the old schema, close it, then open it with the new plan.

```swift
import Testing
import SwiftData
import Foundation

@Test func upgradesFromV1() throws {
    let url = FileManager.default.temporaryDirectory
        .appending(path: "trips-\(UUID().uuidString).store")

    do {
        let old = try ModelContainer(for: Schema(versionedSchema: SchemaV1.self),
                                     configurations: ModelConfiguration(url: url))
        let ctx = ModelContext(old)
        ctx.insert(SchemaV1.Trip(name: "Lisbon", start: .now))
        ctx.insert(SchemaV1.Trip(name: "Lisbon", start: .now.addingTimeInterval(86_400)))
        try ctx.save()
    }

    let new = try ModelContainer(for: Schema(versionedSchema: SchemaV3.self),
                                 migrationPlan: TripsMigrationPlan.self,
                                 configurations: ModelConfiguration(url: url))
    let trips = try ModelContext(new).fetch(FetchDescriptor<SchemaV3.Trip>())
    #expect(trips.count == 2)
    #expect(trips.allSatisfy { $0.notes.isEmpty })
}
```

Also keep a real store file from each released version under `Tests/Fixtures/` (copy it
out of a simulator's app container after using that build) and open it in a test. A real
store catches things a synthetic one misses, such as data written by older code paths.

## 5. Coming from Core Data

- SwiftData can open an existing Core Data store when the `@Model` classes match the
  entity names and attributes and the container points at the same store URL. Generate
  starting models from the `.xcdatamodeld` with Xcode's "Create SwiftData Code" action
  and diff them carefully.
- Coexistence: Core Data and SwiftData can share one store if both stacks use the same
  URL and persistent history tracking is enabled; keep entity class names distinct
  (for example prefix Core Data classes) to avoid symbol clashes.
- Migrate screen by screen; keep writes in one stack at a time per entity to avoid
  conflicting merges.

## 6. Failure handling in production

- Log the error when `ModelContainer` creation fails and show a recoverable error
  screen. Do not delete and recreate the store automatically.
- If a store is genuinely corrupt, move it aside (rename) instead of deleting, so support
  can recover data, and only after the user agrees.

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem