Persisting Foundation Models Output with SwiftData: Storing Generated Structs Safely
- Author
- Ehsan Azish · 3NSOFTS
- Updated
- June 2026
- Read time
- 14 min read
- Level
- Intermediate
- Platform
- iOS 26+, Foundation Models, SwiftData
Implementation Notes
- ~/ What broke: Generated output was persisted before it was validated and versioned.
- ~/ What to do: Validate, version, and persist generated output only after the model response is complete.
Generating structured data with @Generable is the easy part. Storing it durably — surviving app launches, syncing across devices, and tolerating the fact that your AI output shape will change between releases — is where apps get into trouble. This guide covers the architecture that keeps generated content stable in SwiftData.
Don't make your @Generable type your @Model type
The tempting shortcut is to annotate one struct with both @Generable and persistence. Resist it. These two types have different lifecycles and different reasons to change:
- A
@Generabletype is a prompt contract — its shape is tuned for what the model generates well, and you'll tweak it as you refine prompts. - A
@Modeltype is a storage contract — its shape must remain stable and migratable, because changing it triggers SwiftData schema migration.
Coupling them means every prompt-tuning tweak becomes a database migration. Keep them separate and map between them.
import FoundationModels
import SwiftData
// Generation contract — free to evolve with your prompts.
@Generable
struct RecipeDraft {
@Guide(description: "The recipe title")
let title: String
@Guide(description: "Ingredient lines")
let ingredients: [String]
@Guide(description: "Step-by-step instructions")
let steps: [String]
}
// Storage contract — stable, migratable, what actually persists.
@Model
final class Recipe {
var title: String
var ingredients: [String]
var steps: [String]
var generatedAt: Date
var schemaVersion: Int // see "schema drift" below
init(from draft: RecipeDraft, schemaVersion: Int) {
self.title = draft.title
self.ingredients = draft.ingredients
self.steps = draft.steps
self.generatedAt = .now
self.schemaVersion = schemaVersion
}
}
The mapping initializer is the seam. When the @Generable shape changes, you update the mapping, not the stored schema.
Persisting the result
Generate, map, insert — on the main actor for the SwiftData context:
@MainActor
func generateAndStore(prompt: String, context: ModelContext) async throws {
let session = LanguageModelSession()
let draft = try await session.respond(
to: prompt,
generating: RecipeDraft.self
)
let recipe = Recipe(from: draft.content, schemaVersion: currentSchemaVersion)
context.insert(recipe)
try context.save()
}
Generate off the main actor if you prefer (the session work is async), but the ModelContext mutation and save() belong on the actor that owns the context. Mixing context access across actors is the most common SwiftData concurrency bug, and Swift 6 strict concurrency will flag it.
Handling schema drift
Here's the problem unique to AI-generated content: the shape of what you generate changes over time, but your database holds rows generated by older shapes. A Recipe stored in v1 (no prepTime) coexists with v2 rows that have it.
Two defenses:
1. Stamp every row with the schema version that produced it. That's the schemaVersion field above. It lets you reason about, migrate, or re-generate stale rows later.
2. Make new fields optional, and backfill lazily. When you add prepTime to both the draft and the model, make the stored property optional so old rows remain valid without a destructive migration:
@Model
final class Recipe {
// ... existing fields ...
var prepTimeMinutes: Int? // optional: v1 rows are nil, valid
/// Backfill on demand for old rows, rather than migrating everything at once.
func backfillIfNeeded(using session: LanguageModelSession) async {
guard prepTimeMinutes == nil else { return }
// re-generate just the missing field, or compute a default
}
}
This avoids the trap of a giant one-shot migration that re-runs the model over your entire store on upgrade — slow, battery-hostile, and prone to the context-window and rate-limit errors covered elsewhere in this series.
CloudKit sync considerations
If your SwiftData store syncs via CloudKit, generated content needs extra care:
- CloudKit requires optional or defaulted properties for new fields anyway — which aligns perfectly with the lazy-backfill approach above.
- Don't sync the
@Generabledrafts, only the mapped@Modelrows. Drafts are transient; syncing them wastes the CloudKit quota and leaks prompt-shape churn into your sync schema. - Generation is device-local; storage is shared. A recipe generated on the user's iPhone syncs to their Mac as data — the Mac doesn't (and shouldn't) re-generate it. This separation is exactly why the
@Generable/@Modelsplit matters: the stored type is the thing that crosses devices.
If you're bridging generation on one device to storage/relay on another (e.g. a Mac app writing to a shared container that an iOS app consumes), keep the generated
@Modelrows as the single source of truth in the shared container and never re-run the model on the consuming side.
Re-generation vs. caching
Decide explicitly whether stored output is a cache (regenerable, can be thrown away and rebuilt) or a record (the user's data, must be preserved verbatim):
- Cache: a summary you can regenerate from the source document anytime. Safe to invalidate when your prompt or schema improves. Store the source; treat the generation as derived.
- Record: content the user edited, saved, or relies on. Never silently regenerate — that would overwrite their data. Stamp it, version it, preserve it.
Most bugs here come from treating a record like a cache and regenerating over user-modified content. Be deliberate.
Production checklist
- Separate
@Generable(prompt contract) from@Model(storage contract) — map between them. - Stamp every stored row with its
schemaVersion. - New fields are optional + lazily backfilled — never a destructive one-shot migration.
- Mutate
ModelContexton its owning actor; generation can be off-actor. - Sync mapped rows, not drafts, and never re-generate on the consuming device.
- Decide cache vs. record per feature — never regenerate over user-edited records.
Why this matters for shipped apps
AI-generated content ages differently from normal app data: the generator evolves faster than the schema, so you end up with a store full of rows produced by shapes that no longer exist. Apps that don't plan for this hit ugly migrations and silent data loss a few releases in. The split-type, version-stamped, lazy-backfill architecture keeps generated content stable across every prompt tweak and OS update.
This persistence architecture is the kind of thing we get right up front in our on-device AI integration and architecture audit work at 3NSOFTS.