Tutorials › Launch Lab Notes › Chapter 14

The Schema Moved: keyNotFound on Disk, Migration in the Other Column

LabChapter 14 of the Launch Lab Notes20 minAugust 19, 2026Intermediate

The file on disk did not get the memo.

Chapter 13 proved three stores can survive a kill. This one asks what happens when the bytes survive but the shape does not. Schema 1 is still on disk. The app now speaks schema 2. One loader crashes into a real DecodingError. The other migrates first.

Seed writes schema 1 JSON, strict decode fails with keyNotFound for build, migrate succeeds with schema 2 and build 418.

What is actually on disk

PHASE LOADING: ON DISK shows schema 1 JSON for Harbor build 418. STRICT already FAILED with keyNotFound build; MIGRATE still pending.
{"text":"Harbor build 418","schema":1}

No build field. That is the entire conflict.

Strict v2 — the control

let note = try JSONDecoder().decode(NoteV2.self, from: data)

NoteV2 requires schema, text, and build. Feeding it the v1 bytes fails. The still prints the error the runtime threw — not a paraphrase:

DecodingError.keyNotFound
key: build
path:
No value associated with key
CodingKeys(stringValue: "build", intValue: nil) ("build").
SETTLED: ON DISK still schema 1. STRICT FAILED with DecodingError.keyNotFound key build. MIGRATE OK with schema 2 · Harbor build 418 · build 418.
LoaderResult
STRICT v2FAILED · keyNotFound · build
MIGRATEOK · schema 2 · Harbor build 418 · build 418

ON DISK stays schema 1 in the settled still so the error still matches the bytes. The first build of this bench rewrote the file after migrating, which made the money shot lie — disk said v2 while STRICT still showed a v1 failure. Leaving v1 on disk was the fix.

Surviving bytes are not a surviving API

Chapter 13's JSON card can keep Harbor build 418 forever and still brick the next release if you add a required field and decode with no migration. Persistence without versioning is how "it worked in TestFlight" becomes "everyone's data vanished on update" — the file is there; your decoder refused it.

Migrate — read the version, then map

static func load(_ data: Data) throws -> NoteV2 {
  let probe = try JSONDecoder().decode(SchemaProbe.self, from: data)
  switch probe.schema {
  case 2:
    return try JSONDecoder().decode(NoteV2.self, from: data)
  case 1:
    let v1 = try JSONDecoder().decode(NoteV1.self, from: data)
    let build = Int(v1.text.split(separator: " ").last ?? "0") ?? 0
    return NoteV2(schema: 2, text: v1.text, build: build)
  default:
    throw MigrationError.unsupportedSchema(probe.schema)
  }
}

The build number 418 is parsed from the v1 text for this demo. In a shipping app that value would come from a real field or a mapping model — the point is the branch on schema, not the parse.

Same idea as Core Data lightweight migration

The Versioning & Migration chapter talks about inferred mapping models and flags on a Core Data stack. This bench uses Codable so the failure text fits in a still. The knowledge point is identical: old store + new model ⇒ automatic upgrade when the change is safe, or an explicit map / a hard error when it is not. Here the unsafe change is a new required key, and the hard error is keyNotFound.

Mini-exercise

Add "build": 0 to the seeded v1 JSON by hand (still schema: 1). Re-run. STRICT may now succeed while lying about the schema version — proof that checking schema beats hoping the keys look right.

Challenges

  1. Add schema 3 with a renamed field and show both a failing strict decode and a two-step migrator (1→2→3).
  2. Re-enable writing the migrated v2 back to disk, then launch a third panel that only knows v2 — photograph the clean load.
  3. Throw on schema == 1 with no migrator and show your own error string next to DecodingError.
  4. Port the same two-column UI onto a Core Data store with NSMigratePersistentStoresAutomaticallyOption off vs on, and quote the NSError.
  5. XCUITest: assert the STRICT card's static text contains keyNotFound and the MIGRATE card contains build 418.

Key Points

Next up: lists at scale — 10,000 rows in List vs LazyVStack vs eager VStack, with the ch6 body-run counter and a frame-time readout.

SwiftUI
SwiftUI tutorials for building native app screens, layouts, navigation, and state-driven interfaces.
Swift
Swift fundamentals for app developers who want to understand the language behind real iOS and macOS apps.
Ship iOS
Shipping workflows for iOS apps.
📚 Go deeper with LIPAI WANG’s hands-on Udemy bootcampsBrowse all courses →
← Ch 13: What Survives a RelaunchCh 15: Ten Thousand Rows→
SwiftUIUltimate SwiftUI SeriesSwiftUI tutorials for building native app screens, layouts, navigation, and state-driven interfaces.SwiftUltimate Swift SeriesSwift fundamentals for app developers who want to understand the language behind real iOS and macOS apps.Ship iOSShip iOS Apps SeriesShipping workflows for iOS apps: signing, TestFlight, App Store Connect, CI, and release hygiene.

Ship your apps faster

When you're ready to publish your Swift app to the App Store, Simple App Shipper handles metadata, screenshots, TestFlight, and submissions — all in one place.

Try Simple App Shipper
5 free articles remainingSubscribe for unlimited access