Loading JSON in three steps
Loading data from an API is like ordering food for delivery:
- Place the order: send a request to a URL.
- Wait without blocking anything else: with
await, your app stays responsive while the data travels. - Unpack and check: did the right thing arrive (the HTTP status), and can you read it (decode the JSON into your types)?
Most networking bugs come from skipping part of step 3.
URLSession.data(for:) is an async throws API that integrates with structured concurrency: it suspends without blocking a thread and is cancelled when the calling task is cancelled (a SwiftUI .task going away cancels the request). It throws only for transport failures (URLError); HTTP error statuses arrive as successful responses you must check. Codable synthesizes decoding from your property names, with JSONDecoder strategies for keys and dates.
The production concerns are where defaults surprise you: synthesized Decodable ignores property defaults, the default date strategy isn’t ISO 8601, decoding large payloads on the main actor causes hitches, and testability needs a seam (URLProtocol stubs). The Detailed version covers them.
struct Product: Decodable, Identifiable {
let id: Int
let name: String
let price: Double
}
enum APIError: Error {
case badStatus(Int)
}
func loadProducts() async throws -> [Product] {
let url = URL(string: "https://api.example.com/products")!
let (data, response) = try await URLSession.shared.data(from: url)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
throw APIError.badStatus((response as? HTTPURLResponse)?.statusCode ?? 0)
}
return try JSONDecoder().decode([Product].self, from: data)
}Line by line:
Decodabletells Swift to generate the code that reads aProductfrom JSON. The property names must match the JSON keys:{"id": 1, "name": "Mug", "price": 9.5}.try await URLSession.shared.data(from:)sends the request and waits for the answer.try, because it fails when there’s no connection or the request times out.- The
guardchecks the status code. A 404 or a 500 is still a “response”, so URLSession doesn’t throw for it. You have to check. JSONDecoder().decodeturns the bytes into[Product], and throws if the JSON doesn’t match.
The response nobody checked
Tap the line with the bug.
func loadOrders() async throws -> [Order] {
let url = URL(string: "https://api.example.com/orders")!
let (data, _) = try await URLSession.shared.data(from: url)
let page = try? JSONDecoder().decode(OrderPage.self, from: data)
return page?.orders ?? []
}Hint: The server answers 503 with an HTML error page. Does data(from:) throw? What does the decoder do with HTML?
Two problems work together here:
- Line 3 throws the response away.
URLSessiononly throws for transport failures (no internet, timeout). A401,404or503is a successful round trip, so the code never learns the server said “no”. - Line 4 swallows the decoding error with
try?, turning “the server is down” into “you have no orders”. Users see an empty screen, not an error, and the crash dashboard stays silent.
Check the status, and let errors propagate to code that shows them:
func loadOrders() async throws -> [Order] {
let url = URL(string: "https://api.example.com/orders")!
let (data, response) = try await URLSession.shared.data(from: url)
guard let http = response as? HTTPURLResponse else { throw APIError.badStatus(0) }
guard (200..<300).contains(http.statusCode) else { throw APIError.badStatus(http.statusCode) }
return try JSONDecoder().decode(OrderPage.self, from: data).orders
}Matching real JSON
APIs rarely use Swift’s naming style. Codable has three tools for the difference:
// {"id": 7, "display_name": "Ana", "created_at": "2026-11-01T09:30:00Z", "avatar_url": null}
struct User: Decodable {
let id: Int
let displayName: String // from display_name, via the key strategy
let createdAt: Date // from an ISO 8601 string, via the date strategy
let avatarURL: URL? // optional: null or a missing key both become nil
enum CodingKeys: String, CodingKey {
case id, displayName, createdAt
case avatarURL = "avatarUrl" // after snake case conversion, avatar_url becomes avatarUrl
}
}
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .iso8601
let user = try decoder.decode(User.self, from: data)keyDecodingStrategy = .convertFromSnakeCasemapsdisplay_nametodisplayNamefor every key.CodingKeyslets you rename individual properties. Note the gotcha above:avatar_urlconverts toavatarUrl, notavatarURL, so acronyms need an explicit key.- Optionals decode
nulland missing keys asnil. Everything that isn’t optional must be present.
That last rule has a trap:
Predict the output
The server sends {"name": "Mug"}. What happens?
struct Product: Decodable {
let name: String
var discount: Double = 0
}
let product = try JSONDecoder().decode(Product.self, from: Data(#"{"name": "Mug"}"#.utf8))
print(product.name, product.discount)The synthesized Decodable initializer ignores default values: for a non-optional property it requires the key, so decoding throws DecodingError.keyNotFound for discount. To make a field optional in the JSON, make it optional in Swift (var discount: Double?) and apply the default where you use it, or write init(from:) with decodeIfPresent(_:forKey:) ?? 0.
Dates by default
Predict the output
The server sends {"createdAt": "2026-11-01T10:00:00Z"}. What happens with a default JSONDecoder?
struct Event: Decodable {
let createdAt: Date
}
let event = try JSONDecoder().decode(Event.self, from: Data(#"{"createdAt": "2026-11-01T10:00:00Z"}"#.utf8))
print(event.createdAt)The default dateDecodingStrategy is .deferredToDate, which expects a number: seconds since 1 January 2001 (Apple's reference date), not 1970 and not a string. An ISO 8601 string throws DecodingError.typeMismatch. Set .iso8601, or a custom .formatted/.custom strategy if the server includes fractional seconds (2026-11-01T10:00:00.123Z), which .iso8601 rejects. And when a backend sends Unix timestamps, use .secondsSince1970, or every date is off by 31 years.
Testing networking code
Two seams cover nearly everything:
- A protocol or closure in front of the client (
var send: (URLRequest) async throws -> (Data, URLResponse)) for view-model tests that only care about results. - A
URLProtocolsubclass registered on aURLSessionConfigurationto test the real client end to end: it intercepts requests and returns canned data, status codes and errors, so status handling, decoding and error mapping are all covered without a network.
Deep dives
Deep diveReuse one URLSessionInterview favorite
Each URLSession has its own connection pool. Creating a new session per request throws away open connections, so every request pays for a new TCP and TLS handshake and loses HTTP/2 multiplexing. Use URLSession.shared or one long-lived session per configuration (for example, one with your timeouts and cache).
A session created with a delegate keeps a strong reference to that delegate until the session is invalidated (finishTasksAndInvalidate() or invalidateAndCancel()). Creating sessions with self as the delegate and never invalidating them leaks both, which is a common finding in memory graphs.
data(for:)throws only for transport failures; checkHTTPURLResponse.statusCode. Nevertry?a decode in a loading path.- Synthesized
Decodableignores property defaults (missing key throws); the default date strategy is seconds since 2001;.convertFromSnakeCaseturnsavatar_urlintoavatarUrl. - Test with a
URLProtocolstub on a customURLSessionConfiguration.
Check this part
The server answers 404. Does URLSession.data(from:) throw?
Why is try? around decoding dangerous in a loading function?
What does JSONDecoder's default date strategy expect?
A model has
var discount: Double = 0, and the JSON has no "discount" key. What does synthesized decoding do?