How do you make schema, API and data changes backward compatible: expand and contract, tolerant readers, forward-only migrations and minimum versions?

25.5.1 · L5 · Interview

Predict first

A model with optional new fields reads old data and ignores an unknown key. A model with a new required field reads the same old data. What happens?

import Foundation

struct ProfileV1: Codable { var name: String }
struct ProfileV2: Codable { var name: String; var nickname: String?; var plan: String? }
struct StrictV3: Codable { var name: String; var plan: String }

let old = #"{"name":"Ana"}"#.data(using: .utf8)!
let new = #"{"name":"Ana","nickname":"A","plan":"pro","futureField":42}"#.data(using: .utf8)!
let decoder = JSONDecoder()
print("v2 reads old:", (try? decoder.decode(ProfileV2.self, from: old)) != nil)
print("v1 reads new (unknown keys ignored):", (try? decoder.decode(ProfileV1.self, from: new)) != nil)
print("strict v3 reads old:", (try? decoder.decode(StrictV3.self, from: old)) != nil)
  • v2 reads old: true; v1 reads new: true; strict v3 reads old: false
  • All three succeed
  • All three fail

Mental model. Changing a rail gauge while trains are running means laying a third rail first, moving trains over time, and only then pulling up the old one. You never swap the track in one night.

You cannot update all clients at once, and you cannot roll back a migrated local database, so change things in a way that works for the versions on both sides. The core technique is expand and contract (parallel change): first expand by adding the new thing alongside the old one (a new optional field or column, a new endpoint version) while still supporting the old; ship code that writes both and reads the new with a fallback to the old; migrate existing data in the background; and only when old versions are gone (measured with analytics) contract by removing the old path. Never rename or remove something in one step. For data you read, be a tolerant reader: ignore unknown keys, treat new fields as optional with defaults, handle unknown enum cases with a fallback instead of failing, and do not assume ordering. The check below decodes with three models: a version that adds optional fields still reads old data and ignores a field it does not know, while a model that makes a new field required fails on old data, which is exactly the crash that appears when a server response or a stored file predates the change.

Local persistence migrates forward only (Q331, Q334): a Core Data or SwiftData store, or a file format, upgraded by a new version cannot be opened by an older one, so store a schema version and make an older build fail gracefully (a clear message, or read-only) instead of crashing or, worse, overwriting newer data, which matters because testers, family-shared devices and reinstalled older builds do happen. Test migrations from every supported old version with real fixture files, not only the previous one. For APIs, version them (a path or header), keep old versions alive for as long as old clients exist, add a minimum supported version check with a soft and a forced update prompt, and let the server tell the client what it supports. Shared containers need extra care: the app, its extensions and the watch app (Q413) may run different versions against the same App Group data, so the schema must be readable by all of them during an update. And write the plan for undoing a change, because the realistic undo is another forward migration.

Optional fields and ignored keys give compatibility in both directions; a new required field breaks on data written before it existed.

Common traps

  • Making a new field required in the model decoded from old data. Old payloads or files no longer decode and the app fails at launch.
  • Assuming users only run the latest version. Old versions stay in use for months, and old data stays on disk for years.

They might follow up with

  • What is expand and contract? Add the new alongside the old, migrate, and remove the old only when nobody uses it.
  • What should an older app do with a newer store? Detect the newer schema version and avoid writing; show a message or read only.

Related questions

  • 25.3.3 What can and cannot be rolled back after release, and how do you respond to a bad release: pause, flags…
  • 25.4.1 How do you design feature flags and remote config for a mobile app: flag kinds, defaults, stable percentage…
  • 25.5.2 What do the deployment target, availability checks and required device capabilities decide, and how do you…
  • 13.2.2 How do you use SQLite safely and quickly: bound parameters, indexes, and schema migrations?
  • 13.3.3 How do Core Data migrations work: lightweight, staged and heavyweight, and what about CloudKit?
  • 15.5.2 How do you evolve a public API without breaking callers: deprecation, renames, versioning and source versus…
  • 19.7.2 How do you treat extensions, widgets and intents as a public API of the product: shared modules, data…

What do the deployment target, availability checks and required… →