Home

The Codable conformances that break in production

Codable is one of Swift’s best features and it makes a set of assumptions about your JSON that real APIs routinely violate. Every one of these has cost me a production bug.

1. A missing key is a thrown error, not nil

struct User: Codable {
    let id: Int
    let nickname: String?
}

If nickname is absent from the JSON entirely, this works — a missing key decodes an Optional as nil. That much is fine.

What surprises people is the reverse: a non-optional property whose key is missing throws keyNotFound, and because Codable decoding is all-or-nothing, the entire object fails. One field the backend stopped sending breaks the whole screen.

The defensive version, for fields you do not control:

struct User: Codable {
    let id: Int
    let name: String
    let bio: String

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        id = try container.decode(Int.self, forKey: .id)
        name = try container.decode(String.self, forKey: .name)
        bio = try container.decodeIfPresent(String.self, forKey: .bio) ?? ""
    }
}

decodeIfPresent is the tool. Reserve hard decode for fields whose absence genuinely means the response is invalid.

2. One bad element kills the whole array

let users = try JSONDecoder().decode([User].self, from: data)

A thousand users, one with a null where a string was expected, and you get zero users.

The fix is a wrapper that decodes elements individually:

struct Lossy<T: Decodable>: Decodable {
    let elements: [T]

    init(from decoder: Decoder) throws {
        var container = try decoder.unkeyedContainer()
        var result: [T] = []
        while !container.isAtEnd {
            if let element = try? container.decode(T.self) {
                result.append(element)
            } else {
                _ = try? container.decode(AnyDecodable.self)   // skip the bad one
            }
        }
        elements = result
    }
}

The else branch matters: an unkeyed container does not advance on a failed decode, so without consuming the element you get an infinite loop. That is a fun one to debug.

3. Dates

JSONDecoder’s default is .deferredToDate, which expects a Unix timestamp as a Double — almost certainly not what your API sends.

decoder.dateDecodingStrategy = .iso8601

And ISO 8601 itself is not one format. 2026-07-08T10:30:00Z parses; 2026-07-08T10:30:00.123Z does not, because the built-in strategy rejects fractional seconds. Backends add milliseconds without considering it a breaking change, and it is a breaking change for you.

decoder.dateDecodingStrategy = .custom { decoder in
    let string = try decoder.singleValueContainer().decode(String.self)
    let formatter = ISO8601DateFormatter()
    formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
    if let date = formatter.date(from: string) { return date }
    formatter.formatOptions = [.withInternetDateTime]
    if let date = formatter.date(from: string) { return date }
    throw DecodingError.dataCorruptedError(in: try decoder.singleValueContainer(),
                                           debugDescription: "bad date: \(string)")
}

Accepting both is two lines and removes an entire category of 3am incident.

4. Numbers that are sometimes strings

Some backends send "id": 42 and sometimes "id": "42", usually because a different service wrote that field.

init(from decoder: Decoder) throws {
    let container = try decoder.container(keyedBy: CodingKeys.self)
    if let intValue = try? container.decode(Int.self, forKey: .id) {
        id = intValue
    } else {
        let stringValue = try container.decode(String.self, forKey: .id)
        guard let parsed = Int(stringValue) else {
            throw DecodingError.dataCorruptedError(forKey: .id, in: container,
                                                   debugDescription: "not a number")
        }
        id = parsed
    }
}

Ugly, and better than a crash.

5. Enums from server strings

enum Status: String, Codable {
    case active, archived, deleted
}

The day the backend adds suspended, every object containing a Status fails to decode. The enum you wrote is a closed set and the server’s is not.

enum Status: String, Codable {
    case active, archived, deleted
    case unknown

    init(from decoder: Decoder) throws {
        let raw = try decoder.singleValueContainer().decode(String.self)
        self = Status(rawValue: raw) ?? .unknown
    }
}

Warning

This is the failure I have seen break the most apps, because it is invisible until the backend ships. Any enum decoded from a server-controlled string needs an unknown case. Treat it as a hard rule.

6. keyDecodingStrategy and acronyms

.convertFromSnakeCase turns user_id into userId, which is what you want, and it also turns user_url into userUrl — not userURL. If your Swift property follows the API design guidelines and capitalises the acronym, it will not match.

Either name the property userUrl, or write the CodingKeys explicitly. I now write CodingKeys explicitly for anything with an acronym in it, because the failure is a decode error at runtime rather than anything the compiler notices.

The habit that catches all of them

Log the decoding error properly. DecodingError carries the exact coding path and the reason, and most apps throw that away:

catch let error as DecodingError {
    switch error {
    case .keyNotFound(let key, let context):
        log("missing \(key.stringValue) at \(context.codingPath)")
    case .typeMismatch(let type, let context):
        log("expected \(type) at \(context.codingPath)")
    case .valueNotFound(let type, let context):
        log("null for non-optional \(type) at \(context.codingPath)")
    case .dataCorrupted(let context):
        log("corrupted at \(context.codingPath): \(context.debugDescription)")
    @unknown default:
        log("\(error)")
    }
}

“The screen is blank” becomes “missing avatar_url at [0].profile”, which is the difference between an afternoon and a minute.