Codable and JSON
You will buildA store that saves the trips as JSON in a file and reads them back at launch — proven by stopping the app and running it again — and a row of ideas in the planner, read from a JSON feed in the style of a web API, after walking into Codable's best-known trap and out again.
TimeAbout 70 minutes
WhyPart 10 is data: getting it in and out of the app. Nearly all of it travels as JSON, and in Swift JSON goes through Codable — two protocols, Encodable and Decodable, that the compiler can implement for you. Trips has carried a "a real save comes in Part 10" comment since lesson 6; in this lesson it comes. The second half is reading JSON you did not write, where the names and shapes are someone else's — and where the errors, read properly, say exactly what is wrong.
Open your Trips project as lesson 32 left it: double-click Trips.xcodeproj in Finder, or use File ▸ Open… (⌘O) in Xcode.
If you did not finish lesson 32, or would rather start from a known state, download Trips-lesson-32.zip instead. It is the project exactly as it stood at the end of lesson 32. Unzip it and open Trips.xcodeproj.
Run it (⌘R) to check that it builds. You should see where lesson 32 ended:

Since lesson 32, Trip is Codable: the compiler wrote the code that turns a trip into JSON and back, property by property. Apple's page for Codable: "A type that can convert itself into and out of an external representation." An array of trips is Codable too, because its elements are.
Saving is three lines: a JSONEncoder turns trips into Data, and the data is written to a file in the app's own documents folder — URL.documentsDirectory, which no other app can see. .atomic writes to a temporary file first and swaps it in, so a crash halfway never leaves half a file. Reading is the same backwards, in the store's init: if there is a file and it decodes, those are the trips; otherwise, the samples. Two choices about the JSON: dates are written as ISO 8601 text rather than the default — a number of seconds since 2001 — and the output is indented and sorted, to be read by people. The decoder has to agree with the encoder about dates. For now save() also prints what it wrote. In TripStore.swift:
/// The trips, for the whole app. `@MainActor`: the views read it, and the views/// are drawn on the main thread.@MainActor@Observablefinal class TripStore { var trips = Trip.samples var trips: [Trip] /// Where the trips are kept between launches: a file in the app's own documents folder. static let file = URL.documentsDirectory.appending(path: "trips.json") /// The trips saved last time — or, when there are none yet, the samples. init() { do { let data = try Data(contentsOf: Self.file) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 trips = try decoder.decode([Trip].self, from: data) } catch { trips = Trip.samples } } /// Adds the trip a draft describes, with an id no other trip has. func add(_ draft: TripDraft) { let id = (trips.map(\.id).max() ?? 0) + 1 trips.append(draft.trip(id: id)) } /// Removes these trips, whatever order they are in. func delete(_ doomed: [Trip]) { let ids = Set(doomed.map(\.id)) trips.removeAll { ids.contains($0.id) } } /// Makes a trip a favorite, or not. func toggleFavorite(_ trip: Trip) { if let index = trips.firstIndex(where: { $0.id == trip.id }) { trips[index].isFavorite.toggle() } } /// Replaces the trip with the same id. func update(_ trip: Trip) { if let index = trips.firstIndex(where: { $0.id == trip.id }) { trips[index] = trip } } /// Moves these trips in front of another one — or, with no other one, to the end. func move(_ ids: [Trip.ID], before target: Trip.ID?) { let moving = trips.filter { ids.contains($0.id) } trips.removeAll { ids.contains($0.id) } let index = target.flatMap { id in trips.firstIndex { $0.id == id } } ?? trips.endIndex trips.insert(contentsOf: moving, at: index) } /// Makes these trips favorites. func makeFavorites(_ dropped: [Trip]) { for trip in dropped { if let index = trips.firstIndex(where: { $0.id == trip.id }) { trips[index].isFavorite = true } } } /// Writes the trips to the file as JSON. func save() { print("Saving \(trips.count) trips") // a real save comes in Part 10 do { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 encoder.outputFormatting = [.prettyPrinted, .sortedKeys] let data = try encoder.encode(trips) try data.write(to: Self.file, options: .atomic) print(String(decoding: data, as: UTF8.self)) } catch { print("Could not save the trips:", error) } }}/// The trips, for the whole app. `@MainActor`: the views read it, and the views
/// are drawn on the main thread.
@MainActor
@Observable
final class TripStore {
var trips: [Trip]
/// Where the trips are kept between launches: a file in the app's own documents folder.
static let file = URL.documentsDirectory.appending(path: "trips.json")
/// The trips saved last time — or, when there are none yet, the samples.
init() {
do {
let data = try Data(contentsOf: Self.file)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
trips = try decoder.decode([Trip].self, from: data)
} catch {
trips = Trip.samples
}
}
/// Adds the trip a draft describes, with an id no other trip has.
func add(_ draft: TripDraft) {
let id = (trips.map(\.id).max() ?? 0) + 1
trips.append(draft.trip(id: id))
}
/// Removes these trips, whatever order they are in.
func delete(_ doomed: [Trip]) {
let ids = Set(doomed.map(\.id))
trips.removeAll { ids.contains($0.id) }
}
/// Makes a trip a favorite, or not.
func toggleFavorite(_ trip: Trip) {
if let index = trips.firstIndex(where: { $0.id == trip.id }) {
trips[index].isFavorite.toggle()
}
}
/// Replaces the trip with the same id.
func update(_ trip: Trip) {
if let index = trips.firstIndex(where: { $0.id == trip.id }) {
trips[index] = trip
}
}
/// Moves these trips in front of another one — or, with no other one, to the end.
func move(_ ids: [Trip.ID], before target: Trip.ID?) {
let moving = trips.filter { ids.contains($0.id) }
trips.removeAll { ids.contains($0.id) }
let index = target.flatMap { id in trips.firstIndex { $0.id == id } } ?? trips.endIndex
trips.insert(contentsOf: moving, at: index)
}
/// Makes these trips favorites.
func makeFavorites(_ dropped: [Trip]) {
for trip in dropped {
if let index = trips.firstIndex(where: { $0.id == trip.id }) {
trips[index].isFavorite = true
}
}
}
/// Writes the trips to the file as JSON.
func save() {
do {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(trips)
try data.write(to: Self.file, options: .atomic)
print(String(decoding: data, as: UTF8.self))
} catch {
print("Could not save the trips:", error)
}
}
}Run the app. The app saves as soon as it starts — the store's trips are watched (lesson 22) — and the console shows the file:
[
{
"city" : "Seattle",
"country" : "United States",
"id" : 1,
"imageName" : "seattle",
"isFavorite" : true,
"nights" : 5,
"notes" : "**Pike Place** early, before the crowds.",
"price" : 1249.5,
"start" : "2027-06-12T04:00:00Z"
},
{
"city" : "Vancouver",
"country" : "Canada",
"id" : 2,
"imageName" : "vancouver",
"isFavorite" : false,
"nights" : 1,
"notes" : "One night only: *Stanley Park* at sunset.",
"price" : 320,
"start" : "2027-06-17T04:00:00Z"
},
{
"city" : "San Pedro de Atacama",
"country" : "Chile",
"id" : 3,
"imageName" : "atacama",
"isFavorite" : true,
"nights" : 12,
"notes" : "Stars. Bring a **warm** coat.",
"price" : 4180,
"start" : "2027-11-02T04:00:00Z"
}
]Every stored property is there, under its own name: the Decimal price as a number, the Date as text, the Markdown in the notes as written. Nothing had to be spelled out, because each property's type is itself Codable.
A file is only worth writing if it is read again. Replace the print of the whole file with a short line, in TripStore.swift:
/// The trips, for the whole app. `@MainActor`: the views read it, and the views/// are drawn on the main thread.@MainActor@Observablefinal class TripStore { var trips: [Trip] /// Where the trips are kept between launches: a file in the app's own documents folder. static let file = URL.documentsDirectory.appending(path: "trips.json") /// The trips saved last time — or, when there are none yet, the samples. init() { do { let data = try Data(contentsOf: Self.file) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 trips = try decoder.decode([Trip].self, from: data) } catch { trips = Trip.samples } } /// Adds the trip a draft describes, with an id no other trip has. func add(_ draft: TripDraft) { let id = (trips.map(\.id).max() ?? 0) + 1 trips.append(draft.trip(id: id)) } /// Removes these trips, whatever order they are in. func delete(_ doomed: [Trip]) { let ids = Set(doomed.map(\.id)) trips.removeAll { ids.contains($0.id) } } /// Makes a trip a favorite, or not. func toggleFavorite(_ trip: Trip) { if let index = trips.firstIndex(where: { $0.id == trip.id }) { trips[index].isFavorite.toggle() } } /// Replaces the trip with the same id. func update(_ trip: Trip) { if let index = trips.firstIndex(where: { $0.id == trip.id }) { trips[index] = trip } } /// Moves these trips in front of another one — or, with no other one, to the end. func move(_ ids: [Trip.ID], before target: Trip.ID?) { let moving = trips.filter { ids.contains($0.id) } trips.removeAll { ids.contains($0.id) } let index = target.flatMap { id in trips.firstIndex { $0.id == id } } ?? trips.endIndex trips.insert(contentsOf: moving, at: index) } /// Makes these trips favorites. func makeFavorites(_ dropped: [Trip]) { for trip in dropped { if let index = trips.firstIndex(where: { $0.id == trip.id }) { trips[index].isFavorite = true } } } /// Writes the trips to the file as JSON. func save() { do { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 encoder.outputFormatting = [.prettyPrinted, .sortedKeys] let data = try encoder.encode(trips) try data.write(to: Self.file, options: .atomic) print(String(decoding: data, as: UTF8.self)) print("Saved \(trips.count) trips: \(data.count) bytes") } catch { print("Could not save the trips:", error) } }}/// The trips, for the whole app. `@MainActor`: the views read it, and the views
/// are drawn on the main thread.
@MainActor
@Observable
final class TripStore {
var trips: [Trip]
/// Where the trips are kept between launches: a file in the app's own documents folder.
static let file = URL.documentsDirectory.appending(path: "trips.json")
/// The trips saved last time — or, when there are none yet, the samples.
init() {
do {
let data = try Data(contentsOf: Self.file)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
trips = try decoder.decode([Trip].self, from: data)
} catch {
trips = Trip.samples
}
}
/// Adds the trip a draft describes, with an id no other trip has.
func add(_ draft: TripDraft) {
let id = (trips.map(\.id).max() ?? 0) + 1
trips.append(draft.trip(id: id))
}
/// Removes these trips, whatever order they are in.
func delete(_ doomed: [Trip]) {
let ids = Set(doomed.map(\.id))
trips.removeAll { ids.contains($0.id) }
}
/// Makes a trip a favorite, or not.
func toggleFavorite(_ trip: Trip) {
if let index = trips.firstIndex(where: { $0.id == trip.id }) {
trips[index].isFavorite.toggle()
}
}
/// Replaces the trip with the same id.
func update(_ trip: Trip) {
if let index = trips.firstIndex(where: { $0.id == trip.id }) {
trips[index] = trip
}
}
/// Moves these trips in front of another one — or, with no other one, to the end.
func move(_ ids: [Trip.ID], before target: Trip.ID?) {
let moving = trips.filter { ids.contains($0.id) }
trips.removeAll { ids.contains($0.id) }
let index = target.flatMap { id in trips.firstIndex { $0.id == id } } ?? trips.endIndex
trips.insert(contentsOf: moving, at: index)
}
/// Makes these trips favorites.
func makeFavorites(_ dropped: [Trip]) {
for trip in dropped {
if let index = trips.firstIndex(where: { $0.id == trip.id }) {
trips[index].isFavorite = true
}
}
}
/// Writes the trips to the file as JSON.
func save() {
do {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(trips)
try data.write(to: Self.file, options: .atomic)
print("Saved \(trips.count) trips: \(data.count) bytes")
} catch {
print("Could not save the trips:", error)
}
}
}Run the app and make Vancouver a favorite with its star. Then stop the app in Xcode (Stop, ⌘.) and run it again (⌘R), tap the Favorites chip, and scroll down to the cards:

Vancouver is still among the favorites after the app was stopped and started again: the store read it back from the file. Stopping gives an app no warning at all, and it lost nothing, because the star's change was saved the moment it happened. The console shows the saves before the restart — one at launch, one for the star, a byte shorter than the first because false became true:
Saved 3 trips: 798 bytes
Saved 3 trips: 797 bytesJSON you write yourself is easy: your names, your shapes. JSON from a web service uses its own. Here is a small feed of trip ideas written the way most web APIs write it, with names in snake_case — suggested_nights, image_url — and an Idea struct with Swift's camelCase names. A decoder can translate between the two: .convertFromSnakeCase, which Apple describes as "A key decoding strategy that converts snake-case keys to camel-case keys." For now the feed is a string in the app; in the next lesson, data like it arrives over the network. Make a new Swift File named Ideas, and replace everything in it:
import Foundation
/// Ideas for trips, written the way a web service writes JSON: names in snake_case.
let ideasJSON = """
[
{"city": "Bergen", "country": "Norway", "suggested_nights": 4, "image_url": "https://example.com/bergen.jpg"},
{"city": "Kyoto", "country": "Japan", "suggested_nights": 7, "image_url": "https://example.com/kyoto.jpg"}
]
"""
/// One idea from the feed.
struct Idea: Decodable, Identifiable {
let city: String
let country: String
let suggestedNights: Int
let imageURL: URL
var id: String {
city
}
/// Reads the feed: suggested_nights becomes suggestedNights.
static func load() throws -> [Idea] {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
return try decoder.decode([Idea].self, from: Data(ideasJSON.utf8))
}
}A row of buttons shows the ideas — or, if the feed cannot be read, what the decoder said. Result(catching:) runs a throwing call and keeps either the value or the error. Make a new SwiftUI View named IdeasRow, and replace everything in it:
import SwiftUI
/// The feed's ideas as buttons — or, if the feed cannot be read, why not.
struct IdeasRow: View {
let choose: (Idea) -> Void
var body: some View {
switch Result(catching: { try Idea.load() }) {
case .success(let ideas):
HStack {
Text("Ideas")
.foregroundStyle(.secondary)
ForEach(ideas) { idea in
Button(idea.city) { choose(idea) }
.buttonStyle(.bordered)
}
}
case .failure(let error):
Text("The ideas could not be read: \(String(describing: error))")
.font(.caption)
.foregroundStyle(.red)
.onAppear { print(error) }
}
}
}And the planner shows the row when it is open; choosing an idea fills in the draft. In TripPlanner.swift:
struct TripPlanner: View { @Environment(TripStore.self) private var store @State private var isOpen = false @State private var draft = TripDraft() @Environment(\.horizontalSizeClass) private var sizeClass var body: some View { VStack(spacing: 12) { VStack(alignment: .leading, spacing: 12) { Button(isOpen ? "Close the planner" : "Plan a trip", systemImage: isOpen ? "xmark" : "plus") { isOpen.toggle() } .buttonStyle(.bordered) if isOpen { IdeasRow { idea in draft.city = idea.city draft.country = idea.country draft.nights = idea.suggestedNights } let fields = sizeClass == .regular ? AnyLayout(HStackLayout()) : AnyLayout(VStackLayout(alignment: .leading)) fields { TextField("City", text: $draft.city) TextField("Country", text: $draft.country) } Stepper(value: $draft.nights, in: 1...60) { Text("^[\(draft.nights) night](inflect: true)") } Stepper(value: $draft.price, in: 0...20_000, step: 250) { Text(draft.price, format: .currency(code: "USD")) } HStack { PicturePicker(selection: $draft.imageName) Spacer() FavoriteButton(isFavorite: $draft.isFavorite) } Button("Add to my trips") { store.add(draft) draft = TripDraft() isOpen = false } .buttonStyle(.borderedProminent) .disabled(draft.city.isEmpty) } } .textFieldStyle(.roundedBorder) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal) if isOpen { TripCard(trip: Binding( get: { draft.trip(id: 0) }, set: { draft.isFavorite = $0.isFavorite } )) } } }}struct TripPlanner: View {
@Environment(TripStore.self) private var store
@State private var isOpen = false
@State private var draft = TripDraft()
@Environment(\.horizontalSizeClass) private var sizeClass
var body: some View {
VStack(spacing: 12) {
VStack(alignment: .leading, spacing: 12) {
Button(isOpen ? "Close the planner" : "Plan a trip", systemImage: isOpen ? "xmark" : "plus") {
isOpen.toggle()
}
.buttonStyle(.bordered)
if isOpen {
IdeasRow { idea in
draft.city = idea.city
draft.country = idea.country
draft.nights = idea.suggestedNights
}
let fields = sizeClass == .regular ? AnyLayout(HStackLayout()) : AnyLayout(VStackLayout(alignment: .leading))
fields {
TextField("City", text: $draft.city)
TextField("Country", text: $draft.country)
}
Stepper(value: $draft.nights, in: 1...60) {
Text("^[\(draft.nights) night](inflect: true)")
}
Stepper(value: $draft.price, in: 0...20_000, step: 250) {
Text(draft.price, format: .currency(code: "USD"))
}
HStack {
PicturePicker(selection: $draft.imageName)
Spacer()
FavoriteButton(isFavorite: $draft.isFavorite)
}
Button("Add to my trips") {
store.add(draft)
draft = TripDraft()
isOpen = false
}
.buttonStyle(.borderedProminent)
.disabled(draft.city.isEmpty)
}
}
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
if isOpen {
TripCard(trip: Binding(
get: { draft.trip(id: 0) },
set: { draft.isFavorite = $0.isFavorite }
))
}
}
}
}Run the app and tap Plan a trip:

No ideas. The console has the whole error:
Saved 3 trips: 798 bytes
DecodingError.keyNotFound: Key 'imageURL' not found in keyed decoding container. Path: [0]. Debug description: No value associated with key CodingKeys(stringValue: "imageURL", intValue: nil) ("imageURL").Read it, and it says what is wrong: no key named imageURL. .convertFromSnakeCase turned image_url into imageUrl — its first step is to "Capitalize each word that follows an underscore", and that is all it does to a word — and imageUrl is not imageURL. suggested_nights became suggestedNights without trouble; any name with an acronym in it is the trap. Apple's page says so itself: the strategy "can’t infer capitalization for acronyms or initialisms such as WYSIWYG or URI."
A CodingKeys enum tells Codable which name in the JSON goes with each property. With the snake-case strategy on, the names it compares are the converted ones, so imageURL maps to "imageUrl". Every property needs a case, even the ones whose names already match. In Ideas.swift:
import Foundation/// Ideas for trips, written the way a web service writes JSON: names in snake_case.let ideasJSON = """[ {"city": "Bergen", "country": "Norway", "suggested_nights": 4, "image_url": "https://example.com/bergen.jpg"}, {"city": "Kyoto", "country": "Japan", "suggested_nights": 7, "image_url": "https://example.com/kyoto.jpg"}]"""/// One idea from the feed.struct Idea: Decodable, Identifiable { let city: String let country: String let suggestedNights: Int let imageURL: URL var id: String { city } /// The names the feed uses, once .convertFromSnakeCase has turned image_url into imageUrl. enum CodingKeys: String, CodingKey { case city, country, suggestedNights case imageURL = "imageUrl" } /// Reads the feed: suggested_nights becomes suggestedNights. static func load() throws -> [Idea] { let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase return try decoder.decode([Idea].self, from: Data(ideasJSON.utf8)) }}import Foundation
/// Ideas for trips, written the way a web service writes JSON: names in snake_case.
let ideasJSON = """
[
{"city": "Bergen", "country": "Norway", "suggested_nights": 4, "image_url": "https://example.com/bergen.jpg"},
{"city": "Kyoto", "country": "Japan", "suggested_nights": 7, "image_url": "https://example.com/kyoto.jpg"}
]
"""
/// One idea from the feed.
struct Idea: Decodable, Identifiable {
let city: String
let country: String
let suggestedNights: Int
let imageURL: URL
var id: String {
city
}
/// The names the feed uses, once .convertFromSnakeCase has turned image_url into imageUrl.
enum CodingKeys: String, CodingKey {
case city, country, suggestedNights
case imageURL = "imageUrl"
}
/// Reads the feed: suggested_nights becomes suggestedNights.
static func load() throws -> [Idea] {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
return try decoder.decode([Idea].self, from: Data(ideasJSON.utf8))
}
}Run the app, open the planner, and tap Bergen:

Bergen and Kyoto, read from the feed; a tap on Bergen filled in its city, its country and its four nights.
JSONEncoder, and read back at launch with JSONDecoder: favorites survive a restart..convertFromSnakeCase — and the imageURL trap, read in the DecodingError and fixed with CodingKeys.Next, loading data that takes time to arrive: async/await, a loading state, an error with a Retry button, and pull-to-refresh.
Stuck, or want to compare with your own? This is the project as it stands at the end of this lesson, built and run before it was put here.
Trips.xcodeproj in Xcode.15 cards for this lesson, each labelled with its topic. 9 ask how you would write something; the other 6 are trick questions, written to catch a misunderstanding. Answer in your head first, then tap the card.
Codable value into JSON, and print it.JSONEncoder().encode(_:) gives Data; String(decoding:as:) makes it readable. It printed {"city":"Seattle","nights":5} — but the keys come in no fixed order, and other runs of the same program put them the other way round. Sort them when the order matters (the next card).
JSONDecoder().decode(Trip.self, from: data) — the type to make, and the data. It throws if the JSON does not fit. It printed Seattle 5.
outputFormatting = [.prettyPrinted, .sortedKeys]: one key to a line, indented, in alphabetical order — the first line after the brace was "city" : "Seattle",. Sorted keys also make the output the same every time, which helps in tests.
dateEncodingStrategy = .iso8601 on the encoder and dateDecodingStrategy = .iso8601 on the decoder — the two must agree. It wrote {"start":"1970-01-01T00:00:00Z"}, and read it back as the same date: true.
keyDecodingStrategy = .convertFromSnakeCase. It printed 4. Watch names with acronyms: image_url becomes imageUrl, never imageURL.
A CodingKeys enum, String-backed: each case is a property, and its raw value the key. Every property needs a case. It printed https://example.com/bergen.jpg.
Make the property optional: a missing key — or null — decodes as nil. It printed nil.
Write init(from:) yourself, and reach in with nestedContainer(keyedBy:forKey:). It printed AS 101 08:40.
Catch DecodingError.keyNotFound(let key, let context): the key, and the path to where it should have been. It printed missing: nights at: [].
Date is encoded with a plain JSONEncoder(). What appears in the JSON?A number — and not the Unix one: {"start":-978307200}. The default counts seconds from 1 January 2001, Apple's reference date, so the start of 1970 is negative. Servers usually mean seconds since 1970: say so with .secondsSince1970, or use .iso8601.
.convertFromSnakeCase, does the key image_url fill a property named imageURL?No: "DecodingError.keyNotFound: Key 'imageURL' not found in keyed decoding container. Debug description: No value associated with key CodingKeys(stringValue: "imageURL", intValue: nil) ("imageURL")." The strategy makes imageUrl. Map it with CodingKeys: case imageURL = "imageUrl".
None — the whole decode throws: "DecodingError.keyNotFound: Key 'nights' not found in keyed decoding container. Path: [1]. Debug description: No value associated with key CodingKeys(stringValue: "nights", intValue: nil) ("nights")." The path, [1], is the second element. One bad element sinks the array unless you decode the elements one by one.
No — the extra key is ignored, and gone if you encode the value again: {"name":"Seattle"}. A round trip through your struct drops what it does not know.
String?, and the JSON has "notes": 5. Does it decode as nil?No, it throws: "DecodingError.typeMismatch: Expected value of type String. Path: notes. Debug description: Expected to decode String but found number instead." An optional forgives a missing key or null — not a value of the wrong type.
struct Trip: Codable { let id = UUID(); let city: String }. Is id read from the JSON?No — and the compiler says so: "immutable property will not be decoded because it is declared with an initial value which cannot be overwritten" A let with a value can never be set again, so decoding skips it: this trip got a new id, and the comparison printed `. (An edge case in this lesson tries var`.)
10 questions of the kind an interviewer uses to find out whether you really know this — the corners, the traps, and the things that changed recently. Each has its source. Wherever the answer is what some code does, that code was compiled and run, and what you see under “Show the answer” is what it really did. Say your answer aloud before you open it.
The JSON says "image_url". The struct says let imageURL: URL. The decoder uses .convertFromSnakeCase. What happens — and what are the two fixes?
It throws — no key imageURL: "DecodingError.keyNotFound: Key 'imageURL' not found in keyed decoding container. Debug description: No value associated with key CodingKeys(stringValue: "imageURL", intValue: nil) ("imageURL")."
The strategy capitalizes the word after each underscore and nothing else, so image_url becomes imageUrl. Apple's page for it: it "can’t infer capitalization for acronyms or initialisms such as WYSIWYG or URI", and it points to "a custom CodingKeys enumeration".
Fix one: keep the strategy, and give the converted name — "imageUrl" — in CodingKeys:
Fix two: drop the strategy, and spell out the JSON's own names:
Both printed the URL. The second has one rule fewer to remember: what is in CodingKeys is exactly what is in the JSON.
Where this comes from: JSONDecoder.KeyDecodingStrategy.convertFromSnakeCase · CodingKey — Measured with Xcode 27.
A server sends "at": 1781222400 — seconds since 1970, as servers do. The Date is decoded with a plain JSONDecoder(). What date comes out?
One 31 years late:
It printed
plain: 2057-06-12T00:00:00Z
since 1970: 2026-06-12T00:00:00ZNo error — just a wrong date. The default strategy, .deferredToDate, leaves the work to Date itself, which reads a number as seconds since 1 January 2001, Apple's reference date; a server almost always means 1 January 1970. Apple's page for the date strategies lists .secondsSince1970, .millisecondsSince1970, .iso8601 and others: choose the one the server uses, and test it with a known date.
Where this comes from: JSONDecoder.DateDecodingStrategy · JSONDecoder — Measured with Xcode 27.
A file was written with dateEncodingStrategy = .iso8601. It is read back with a plain JSONDecoder(). What happens?
It throws:
It printed
DecodingError.typeMismatch: Expected value of type Double. Path: start. Debug description: Expected to decode Double but found a string instead.The plain decoder expects dates as numbers and found text. In an app that falls back to sample data when its file does not decode — as this lesson's Trips does — this is silent: every launch starts again from the samples. Keep the encoder's and decoder's settings side by side, as TripStore does, so that they cannot drift apart.
Where this comes from: JSONDecoder.DateDecodingStrategy · DecodingError — Measured with Xcode 27.
A feed of three places is decoded as [Place]; the second has no nights. How many places come back — and how would you keep the good ones?
None — the whole decode throws:
It printed
DecodingError.keyNotFound: Key 'nights' not found in keyed decoding container. Path: [1]. Debug description: No value associated with key CodingKeys(stringValue: "nights", intValue: nil) ("nights").The path says where: [1], the second element. An array decodes all or nothing. To keep the good elements, decode them one at a time from an unkeyed container, and step past the ones that fail:
It printed ["Seattle", "Lima"]. The Skip decode is what moves past a bad element: a decode that fails leaves the container where it was, so without it the loop would try the same element again and again.
Where this comes from: DecodingError · Encoding and Decoding Custom Types — Measured with Xcode 27.
The JSON has a key the struct has no property for. Does decoding fail? And what does encoding the value again write?
It decodes — and the rating is gone: {"name":"Seattle"}.
Unknown keys are ignored, which is what makes a decoder tolerant of a server that adds fields. The cost shows in a round trip: an app that decodes a record, changes one field and sends it all back has deleted every field it did not model. When that matters, model the fields, or send only what changed.
Where this comes from: JSONDecoder — Measured with Xcode 27.
let notes: String?. Three JSON objects: no notes key, "notes": null, and "notes": 5. Which decode?
The first two, as nil; the third throws:
It printed
nil
nil
DecodingError.typeMismatch: Expected value of type String. Path: notes. Debug description: Expected to decode String but found number instead.An optional means this may be absent: a missing key or null both give nil. A value of the wrong type is still a mismatch, and the whole decode fails. If a server sends numbers and strings in the same field, write init(from:) and try each type in turn.
Where this comes from: DecodingError — Measured with Xcode 27.
enum Mode: String, Decodable { case flight, ferry }. The server starts sending "train". What happens — and how do you survive it?
The whole decode fails:
It printed
DecodingError.dataCorrupted: Data was corrupted. Path: mode. Debug description: Cannot initialize Mode from invalid String value trainA new case on the server breaks every installed version of the app that reads it. Give the enum a case for what it does not know, and an init(from:) that falls back to it:
It printed other. Then decide what the app does with .other — show it generically, or hide it — rather than refusing the whole response.
Where this comes from: DecodingError · Encoding and Decoding Custom Types — Measured with Xcode 27.
A total is computed as 0.1 + 0.2 and encoded — once as a Double, once as a Decimal. What does each JSON say?
The Double is off in the 17th digit; the Decimal is exact:
It printed
{"total":0.30000000000000004}
{"total":0.3}A Double is binary, and neither 0.1 nor 0.2 has an exact binary value; their sum shows the error, and the encoder writes it faithfully. Decimal counts in tens, which is why Trips keeps its prices in one. Build a Decimal from a string, as here: Decimal(0.1) would start from the inexact Double.
Where this comes from: Decimal — Measured with Xcode 27.
let id = UUID() in a Codable struct, and the JSON has an id. Is it read? And with var id = UUID()?
It compiles, with a warning
immutable property will not be decoded because it is declared with an initial value which cannot be overwritten
Not with let: the compiler warns, and the id is a new one — the comparison printed ``.
A let that already has a value can never be assigned again, so the decoder the compiler writes leaves it alone. With var, the JSON's id is read — and the key becomes required: the default is not used when it is missing.
It printed
true
DecodingError.keyNotFound: Key 'id' not found in keyed decoding container. Debug description: No value associated with key CodingKeys(stringValue: "id", intValue: nil) ("id").So a default value on a Codable property is not a fallback for missing JSON. For that, make the property optional, or write init(from:) with decodeIfPresent.
Where this comes from: Encoding and Decoding Custom Types — Measured with Xcode 27; the warning is the compiler's own.
An itinerary holds an array of stops; the second stop has no nights. Decoding fails. How do you find out which stop, and what was missing?
From the error's context. Each DecodingError case carries a context with a codingPath: the keys and indexes from the top of the JSON down to the problem.
It printed
missing: nights
path: ["stops", "Index 1"]The stops array, the element at index 1, the key nights. Catch the cases you expect — keyNotFound, typeMismatch, valueNotFound, dataCorrupted — and log the path: it turns "the server sent bad data" into a sentence someone can act on.
Where this comes from: DecodingError — Measured with Xcode 27.