Swift JSON with Codable: JSONDecoder, CodingKeys & Custom Strategies

Last updated:

Swift Codable protocol — a type alias for Encodable & Decodable — enables compile-time JSON serialization: conform a struct to Codable and JSONDecoder().decode(MyType.self, from: data) handles all JSON parsing with no runtime reflection overhead. JSONDecoder decodes a 1 MB JSON payload in ~3 ms on an iPhone 15 — 5× faster than manual JSONSerialization parsing for the same struct; the decoder is not thread-safe, so create one per decode operation or protect with a mutex in concurrent contexts. This guide covers Codable struct definitions, CodingKeys enum for JSON field name mapping, keyDecodingStrategy (.convertFromSnakeCase), custom init(from:) and encode(to:), handling optional fields, AnyCodable for heterogeneous JSON, and URLSession JSON API integration.

Codable Struct Definition and JSONDecoder Basics

Conforming a struct to Codable and calling JSONDecoder().decode() is the starting point for Swift JSON parsing. The compiler automatically synthesizes init(from:) and encode(to:) when all stored properties are themselves Codable. JSONEncoder().encode() returns Data; convert to String with String(data: data, encoding: .utf8) for debugging. Wrap all decode calls in do-catch to handle DecodingError, which carries a detailed context.codingPath array identifying exactly which field caused the failure.

import Foundation

// ── 1. Define a Codable struct ─────────────────────────────────
struct User: Codable {
    let id: Int
    let name: String
    let email: String
    let age: Int?          // Optional — nil if key is missing or null
}

// ── 2. Decode JSON Data → Swift struct ────────────────────────
let json = """
{
    "id": 1,
    "name": "Alice",
    "email": "alice@example.com",
    "age": 30
}
""".data(using: .utf8)!

do {
    let decoder = JSONDecoder()
    let user = try decoder.decode(User.self, from: json)
    print(user.name)   // Alice
} catch let DecodingError.keyNotFound(key, context) {
    print("Missing key: \(key.stringValue) at \(context.codingPath)")
} catch let DecodingError.typeMismatch(type, context) {
    print("Type mismatch: expected \(type) at \(context.codingPath)")
} catch let DecodingError.valueNotFound(type, context) {
    print("Null value for non-optional: \(type) at \(context.codingPath)")
} catch let DecodingError.dataCorrupted(context) {
    print("Corrupted data: \(context.debugDescription)")
}

// ── 3. Encode Swift struct → JSON Data ────────────────────────
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]

let encodedData = try encoder.encode(user)
let jsonString = String(data: encodedData, encoding: .utf8)!
// {
//   "age": 30,
//   "email": "alice@example.com",
//   "id": 1,
//   "name": "Alice"
// }

// ── 4. Decode JSON array ───────────────────────────────────────
let arrayJson = """
[{"id":1,"name":"Alice","email":"a@b.com"},
 {"id":2,"name":"Bob","email":"b@c.com"}]
""".data(using: .utf8)!

let users = try JSONDecoder().decode([User].self, from: arrayJson)
print(users.count)  // 2

// ── 5. Nested struct — synthesized automatically ───────────────
struct Address: Codable {
    let street: String
    let city: String
    let country: String
}

struct UserWithAddress: Codable {
    let id: Int
    let name: String
    let address: Address   // nested — decoder handles recursively
}

DecodingError carries a codingPath array on every case — for a deeply nested failure like users[2].address.city, the path will be [users, Index 2, address, city], making it straightforward to log exactly where decoding failed. Use encoder.outputFormatting = .prettyPrinted during development for readable output; omit it in production to minimize JSON payload size.

CodingKeys: Custom JSON Field Name Mapping

CodingKeys is a nested enum conforming to String, CodingKey that maps Swift property names to JSON key strings. Define it when JSON keys differ from Swift naming conventions, when you need to exclude properties from encoding, or when working with nested containers. A critical rule: once you define CodingKeys, you must list every property you want encoded/decoded — omitting a property from CodingKeys silently excludes it.

// ── 1. Basic CodingKeys — map snake_case JSON to camelCase Swift ─
struct Article: Codable {
    let id: Int
    let title: String
    let authorName: String   // JSON key: "author_name"
    let createdAt: Date      // JSON key: "created_at"
    let viewCount: Int       // JSON key: "view_count"

    enum CodingKeys: String, CodingKey {
        case id
        case title
        case authorName = "author_name"
        case createdAt  = "created_at"
        case viewCount  = "view_count"
    }
}

// ── 2. Exclude a property from encoding (computed/transient) ────
struct Product: Codable {
    let id: Int
    let name: String
    let price: Double
    // 'discountedPrice' is computed — exclude from Codable
    var discountedPrice: Double { price * 0.9 }

    enum CodingKeys: String, CodingKey {
        case id, name, price
        // discountedPrice intentionally omitted → not encoded/decoded
    }
}

// ── 3. Nested CodingKeys — flatten a nested JSON object ─────────
// JSON: { "id": 1, "meta": { "views": 100, "likes": 50 } }
// Swift: flat struct with id, views, likes

struct Post: Codable {
    let id: Int
    let views: Int
    let likes: Int

    enum CodingKeys: String, CodingKey { case id, meta }
    enum MetaKeys: String, CodingKey { case views, likes }

    init(from decoder: Decoder) throws {
        let c    = try decoder.container(keyedBy: CodingKeys.self)
        id       = try c.decode(Int.self, forKey: .id)
        let meta = try c.nestedContainer(keyedBy: MetaKeys.self, forKey: .meta)
        views    = try meta.decode(Int.self, forKey: .views)
        likes    = try meta.decode(Int.self, forKey: .likes)
    }

    func encode(to encoder: Encoder) throws {
        var c    = encoder.container(keyedBy: CodingKeys.self)
        try c.encode(id, forKey: .id)
        var meta = c.nestedContainer(keyedBy: MetaKeys.self, forKey: .meta)
        try meta.encode(views, forKey: .views)
        try meta.encode(likes, forKey: .likes)
    }
}

// ── 4. CodingKeys inheritance caveat ────────────────────────────
// Subclasses CANNOT inherit CodingKeys — must redeclare all keys
class Animal: Codable {
    let name: String
    enum CodingKeys: String, CodingKey { case name }
}

class Dog: Animal {
    let breed: String
    // Must redeclare ALL keys including 'name' from the superclass
    enum CodingKeys: String, CodingKey { case name, breed }

    required init(from decoder: Decoder) throws {
        let c  = try decoder.container(keyedBy: CodingKeys.self)
        breed  = try c.decode(String.self, forKey: .breed)
        try super.init(from: decoder)  // delegates to Animal's init
    }
}

When using CodingKeys alongside keyDecodingStrategy = .convertFromSnakeCase, the CodingKeys raw string values take precedence — the strategy only applies to keys not covered by CodingKeys. This lets you use .convertFromSnakeCase for most fields while defining CodingKeys only for the exceptions.

keyDecodingStrategy and dateDecodingStrategy

keyDecodingStrategy and dateDecodingStrategy are JSONDecoder properties that transform keys and dates globally, eliminating the need for per-field CodingKeys entries. .convertFromSnakeCase is the most common key strategy — it handles the majority of REST API responses where the server uses snake_case. Combining key and date strategies on a single decoder instance applies both transformations in one pass.

import Foundation

// ── 1. .convertFromSnakeCase — no CodingKeys needed ─────────────
struct Event: Codable {
    let eventId: Int          // JSON: "event_id"
    let eventName: String     // JSON: "event_name"
    let startTime: Date       // JSON: "start_time"
    let maxAttendees: Int     // JSON: "max_attendees"
}

let decoder = JSONDecoder()
decoder.keyDecodingStrategy  = .convertFromSnakeCase
decoder.dateDecodingStrategy = .iso8601

let json = """
{
    "event_id": 42,
    "event_name": "WWDC 2026",
    "start_time": "2026-06-08T09:00:00Z",
    "max_attendees": 5000
}
""".data(using: .utf8)!

let event = try decoder.decode(Event.self, from: json)
print(event.eventName)  // WWDC 2026
print(event.startTime)  // 2026-06-08 09:00:00 +0000

// ── 2. Date strategies compared ──────────────────────────────────
// .iso8601                    → "2026-05-20T10:00:00Z"  (no fractional seconds)
// .secondsSince1970           → 1747742400    (JSON number)
// .millisecondsSince1970      → 1747742400000 (JSON number)
// .formatted(dateFormatter)  → custom string format
// .custom(closure)            → full control

// .iso8601 does NOT handle fractional seconds — use .formatted instead:
let isoFormatter = DateFormatter()
isoFormatter.locale     = Locale(identifier: "en_US_POSIX")
isoFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"
isoFormatter.timeZone   = TimeZone(secondsFromGMT: 0)

let decoderWithMs = JSONDecoder()
decoderWithMs.keyDecodingStrategy  = .convertFromSnakeCase
decoderWithMs.dateDecodingStrategy = .formatted(isoFormatter)

// ── 3. Custom key decoding strategy ─────────────────────────────
// Map kebab-case JSON keys (less common) to camelCase Swift
let customDecoder = JSONDecoder()
customDecoder.keyDecodingStrategy = .custom { codingPath in
    let last = codingPath.last!.stringValue
    let camel = last.split(separator: "-").enumerated().map { idx, part in
        idx == 0 ? String(part) : String(part).capitalized
    }.joined()
    return AnyKey(stringValue: camel)!
}

// Helper for custom key strategy
struct AnyKey: CodingKey {
    var stringValue: String
    var intValue: Int?
    init?(stringValue: String) { self.stringValue = stringValue }
    init?(intValue: Int) { self.intValue = intValue; self.stringValue = "\(intValue)" }
}

// ── 4. Encoder key strategy — mirror for round-tripping ─────────
let encoder = JSONEncoder()
encoder.keyEncodingStrategy  = .convertToSnakeCase
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting     = .prettyPrinted

// Encodes { event_id, event_name, start_time, max_attendees }
let encoded = try encoder.encode(event)

A common pitfall: Swift's built-in .iso8601 date strategy was implemented before the ISO 8601 standard added fractional seconds support. APIs that return "2026-05-20T10:00:00.000Z" will throw DecodingError.dataCorrupted with .iso8601 — use .formatted with the SSSZ format string instead. See the JSON performance guide for benchmarks comparing decoder configuration overhead.

Custom init(from:) and encode(to:) for Complex JSON

Custom init(from decoder: Decoder) and encode(to encoder: Encoder) handle JSON shapes that cannot be expressed with synthesized conformance: flattened nested objects, computed properties, asymmetric encode/decode, and JSON arrays at the top level. Implementing either disables synthesis for that direction — if you implement init(from:), Swift no longer synthesizes it, but encode(to:) is still synthesized unless you also implement it.

// ── 1. Custom init — decode + provide defaults ───────────────────
struct Config: Codable {
    let host: String
    let port: Int
    let useTLS: Bool
    let timeout: TimeInterval

    enum CodingKeys: String, CodingKey {
        case host, port
        case useTLS  = "use_tls"
        case timeout
    }

    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        host    = try c.decode(String.self, forKey: .host)
        port    = try c.decodeIfPresent(Int.self,          forKey: .port)    ?? 443
        useTLS  = try c.decodeIfPresent(Bool.self,         forKey: .useTLS)  ?? true
        timeout = try c.decodeIfPresent(Double.self,       forKey: .timeout) ?? 30.0
    }
}

// ── 2. Asymmetric encode — encode computed shape, decode flat ────
struct Temperature: Codable {
    let celsius: Double
    var fahrenheit: Double { celsius * 9 / 5 + 32 }

    enum CodingKeys: String, CodingKey { case celsius, fahrenheit }

    init(from decoder: Decoder) throws {
        let c   = try decoder.container(keyedBy: CodingKeys.self)
        celsius = try c.decode(Double.self, forKey: .celsius)
        // fahrenheit is computed — not decoded from JSON
    }

    func encode(to encoder: Encoder) throws {
        var c = encoder.container(keyedBy: CodingKeys.self)
        try c.encode(celsius,    forKey: .celsius)
        try c.encode(fahrenheit, forKey: .fahrenheit)  // include computed value
    }
}

// ── 3. unkeyedContainer — decode a JSON array ───────────────────
// JSON: [1, 2, 3, 4, 5]  (top-level array of integers)
struct IntList: Codable {
    let values: [Int]

    init(from decoder: Decoder) throws {
        var c = try decoder.unkeyedContainer()
        var result: [Int] = []
        while !c.isAtEnd {
            result.append(try c.decode(Int.self))
        }
        values = result
    }

    func encode(to encoder: Encoder) throws {
        var c = encoder.unkeyedContainer()
        for v in values { try c.encode(v) }
    }
}

// ── 4. nestedUnkeyedContainer — array nested in keyed object ────
// JSON: { "tags": ["swift", "ios", "json"] }
struct TaggedItem: Codable {
    let id: Int
    let tags: [String]

    enum CodingKeys: String, CodingKey { case id, tags }

    init(from decoder: Decoder) throws {
        let c      = try decoder.container(keyedBy: CodingKeys.self)
        id         = try c.decode(Int.self, forKey: .id)
        var tagC   = try c.nestedUnkeyedContainer(forKey: .tags)
        var result: [String] = []
        while !tagC.isAtEnd {
            result.append(try tagC.decode(String.self))
        }
        tags = result
    }
}

When implementing init(from:), always use decodeIfPresent with a ?? default fallback for optional or defaulted fields rather than decode — this makes your model resilient to APIs that evolve by adding optional fields. container.decodeIfPresent returns nil for both missing keys and JSON null, so combine it with nil-coalescing to supply defaults in one expression.

Handling Optional Fields and Default Values

Swift Codable distinguishes between three states for a JSON field: present with a value, present with null, and missing entirely. decodeIfPresent() collapses the latter two into nil, while decode(T?.self) preserves the distinction. Providing default values for missing fields requires a custom init(from:) since synthesized Codable does not support property default values for missing keys.

// ── 1. Optional property — synthesized Codable handles it ───────
struct Profile: Codable {
    let id: Int
    let username: String
    let bio: String?        // nil if missing OR null in JSON
    let avatarUrl: String?  // nil if missing OR null in JSON
}

// ── 2. Default values — requires custom init ─────────────────────
struct Settings: Codable {
    let theme: String
    let fontSize: Int
    let notificationsEnabled: Bool
    let maxResults: Int

    enum CodingKeys: String, CodingKey {
        case theme, fontSize, notificationsEnabled, maxResults
    }

    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        theme                 = try c.decodeIfPresent(String.self, forKey: .theme)                 ?? "system"
        fontSize              = try c.decodeIfPresent(Int.self,    forKey: .fontSize)              ?? 16
        notificationsEnabled  = try c.decodeIfPresent(Bool.self,   forKey: .notificationsEnabled)  ?? true
        maxResults            = try c.decodeIfPresent(Int.self,    forKey: .maxResults)            ?? 20
    }
}

// ── 3. Distinguish null from missing key ──────────────────────────
// JSON A: { "nickname": null }  — explicit null
// JSON B: { }                   — key absent

struct UserDetail: Decodable {
    enum NicknameState {
        case present(String)
        case explicitNull
        case missing
    }
    let nicknameState: NicknameState

    enum CodingKeys: String, CodingKey { case nickname }

    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        if c.contains(.nickname) {
            // Key is present — check if it's null
            if let name = try c.decodeIfPresent(String.self, forKey: .nickname) {
                nicknameState = .present(name)
            } else {
                nicknameState = .explicitNull   // key present, value is null
            }
        } else {
            nicknameState = .missing            // key entirely absent
        }
    }
}

// ── 4. Non-optional with null guard ──────────────────────────────
// Treat null as a decoding failure (throw) for required fields
struct StrictUser: Decodable {
    let id: Int
    let name: String   // must not be null

    enum CodingKeys: String, CodingKey { case id, name }

    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        id   = try c.decode(Int.self, forKey: .id)
        // decode(String.self) throws .valueNotFound if value is null
        name = try c.decode(String.self, forKey: .name)
    }
}

A practical rule of thumb: prefer Optional Swift properties with decodeIfPresent for any API field that might not be present in all versions of the response — API contracts evolve, and lenient decoding prevents your app from crashing when the server omits a previously-required field. Reserve strict decode() for identifier fields (id, type) where absence truly indicates a corrupt or wrong response.

AnyCodable and Heterogeneous JSON

Some JSON APIs return fields whose type varies at runtime — a value field that can be a string, number, boolean, array, or object depending on context. Swift's type system requires a concrete type for Codable, so truly dynamic JSON requires a workaround: AnyCodable (a community-maintained type), a JSONValue enum with associated values, or falling back to JSONSerialization.

// ── 1. JSONValue enum — typed heterogeneous JSON ─────────────────
// No third-party dependency — implement in your codebase
indirect enum JSONValue: Codable {
    case string(String)
    case int(Int)
    case double(Double)
    case bool(Bool)
    case array([JSONValue])
    case object([String: JSONValue])
    case null

    init(from decoder: Decoder) throws {
        let c = try decoder.singleValueContainer()
        if c.decodeNil()                          { self = .null }
        else if let b = try? c.decode(Bool.self)  { self = .bool(b) }
        else if let i = try? c.decode(Int.self)   { self = .int(i) }
        else if let d = try? c.decode(Double.self){ self = .double(d) }
        else if let s = try? c.decode(String.self){ self = .string(s) }
        else if let a = try? c.decode([JSONValue].self)          { self = .array(a) }
        else if let o = try? c.decode([String: JSONValue].self)  { self = .object(o) }
        else { throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Unknown JSON type")) }
    }

    func encode(to encoder: Encoder) throws {
        var c = encoder.singleValueContainer()
        switch self {
        case .null:         try c.encodeNil()
        case .bool(let b):  try c.encode(b)
        case .int(let i):   try c.encode(i)
        case .double(let d):try c.encode(d)
        case .string(let s):try c.encode(s)
        case .array(let a): try c.encode(a)
        case .object(let o):try c.encode(o)
        }
    }
}

// ── 2. Use JSONValue in a Codable struct ─────────────────────────
struct EventPayload: Codable {
    let eventType: String
    let metadata: [String: JSONValue]   // arbitrary key-value map
}

let json = """
{
    "event_type": "purchase",
    "metadata": {
        "amount": 49.99,
        "currency": "USD",
        "items": ["shirt", "hat"],
        "gift": true,
        "coupon": null
    }
}
""".data(using: .utf8)!

let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
let payload = try decoder.decode(EventPayload.self, from: json)

if case .double(let amount) = payload.metadata["amount"] {
    print(amount)  // 49.99
}

// ── 3. JSONSerialization fallback — for truly opaque JSON ────────
// Use when you need [String: Any] and don't control the type
let raw = try JSONSerialization.jsonObject(with: json, options: [])
if let dict = raw as? [String: Any] {
    print(dict["event_type"] as? String ?? "")
}

// ── 4. AnyCodable via open-source library (AnyCodable-FlightSchool)
// import AnyCodable
// struct Flexible: Codable { let data: AnyCodable }
// Wraps Any values — use when JSONValue enum is too verbose

The JSONValue enum approach is preferred over JSONSerialization for codebases that need to stay within Swift's type system and avoid Any casts. Note that Bool must be decoded before Int and Double in the init(from:) implementation — in some JSON decoder implementations, true can be coerced to Int(1) if the integer branch runs first. See the TypeScript JSON types guide for the TypeScript equivalent using discriminated unions.

URLSession JSON API Integration

Combining URLSession async/await with JSONDecoder produces clean, type-safe API clients in Swift. The pattern: fetch Data from a URL, check the HTTP status code, decode success or error responses, and propagate typed errors up the call stack. A generic APIClient eliminates boilerplate across all endpoints.

import Foundation

// ── 1. Simple GET request with async/await ───────────────────────
struct Post: Codable {
    let id: Int
    let title: String
    let body: String
    let userId: Int
}

func fetchPost(id: Int) async throws -> Post {
    let url = URL(string: "https://jsonplaceholder.typicode.com/posts/\(id)")!
    let (data, response) = try await URLSession.shared.data(from: url)

    guard let http = response as? HTTPURLResponse,
          (200..<300).contains(http.statusCode) else {
        throw URLError(.badServerResponse)
    }

    let decoder = JSONDecoder()
    decoder.keyDecodingStrategy = .convertFromSnakeCase
    return try decoder.decode(Post.self, from: data)
}

// Usage: let post = try await fetchPost(id: 1)

// ── 2. POST request with JSON body ───────────────────────────────
struct CreatePostRequest: Encodable {
    let title: String
    let body: String
    let userId: Int
}

func createPost(_ payload: CreatePostRequest) async throws -> Post {
    let url = URL(string: "https://jsonplaceholder.typicode.com/posts")!
    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")

    let encoder = JSONEncoder()
    encoder.keyEncodingStrategy = .convertToSnakeCase
    request.httpBody = try encoder.encode(payload)

    let (data, response) = try await URLSession.shared.data(for: request)

    guard let http = response as? HTTPURLResponse,
          (200..<300).contains(http.statusCode) else {
        // Try to decode an error response body
        let apiError = try? JSONDecoder().decode(APIError.self, from: data)
        throw apiError ?? URLError(.badServerResponse)
    }

    return try JSONDecoder().decode(Post.self, from: data)
}

// ── 3. Error response model ───────────────────────────────────────
struct APIError: Codable, LocalizedError {
    let code: String
    let message: String
    var errorDescription: String? { "\(code): \(message)" }
}

// ── 4. Generic APIClient ──────────────────────────────────────────
struct APIClient {
    private let baseURL: URL
    private let decoder: JSONDecoder
    private let encoder: JSONEncoder

    init(baseURL: URL) {
        self.baseURL = baseURL
        decoder = JSONDecoder()
        decoder.keyDecodingStrategy  = .convertFromSnakeCase
        decoder.dateDecodingStrategy = .iso8601
        encoder = JSONEncoder()
        encoder.keyEncodingStrategy  = .convertToSnakeCase
        encoder.dateEncodingStrategy = .iso8601
    }

    func get<T: Decodable>(_ path: String) async throws -> T {
        let url = baseURL.appendingPathComponent(path)
        let (data, response) = try await URLSession.shared.data(from: url)
        try validate(response: response, data: data)
        return try decoder.decode(T.self, from: data)
    }

    func post<Body: Encodable, Response: Decodable>(
        _ path: String,
        body: Body
    ) async throws -> Response {
        let url = baseURL.appendingPathComponent(path)
        var req = URLRequest(url: url)
        req.httpMethod = "POST"
        req.setValue("application/json", forHTTPHeaderField: "Content-Type")
        req.httpBody = try encoder.encode(body)
        let (data, response) = try await URLSession.shared.data(for: req)
        try validate(response: response, data: data)
        return try decoder.decode(Response.self, from: data)
    }

    private func validate(response: URLResponse, data: Data) throws {
        guard let http = response as? HTTPURLResponse else { throw URLError(.badServerResponse) }
        guard (200..<300).contains(http.statusCode) else {
            if let apiError = try? decoder.decode(APIError.self, from: data) { throw apiError }
            throw URLError(.badServerResponse)
        }
    }
}

// Usage
// let client = APIClient(baseURL: URL(string: "https://api.example.com")!)
// let post: Post = try await client.get("posts/1")
// let newPost: Post = try await client.post("posts", body: CreatePostRequest(title: "Hi", body: "Content", userId: 1))

For iOS targets below iOS 15, replace URLSession.shared.data(from:) with URLSession.shared.dataTask(with:completionHandler:) wrapped in a withCheckedThrowingContinuation to bridge the callback-based API into async/await. The generic APIClient pattern keeps JSONDecoder configuration centralized — change keyDecodingStrategy or dateDecodingStrategy in one place and all endpoints benefit. See the JSON API design guide for REST response structure conventions and the Java JSON with Jackson guide for the equivalent Java pattern.

Key Terms

Codable
A Swift type alias for Encodable & Decodable — a type conforming to Codable can be both encoded to and decoded from an external representation such as JSON. The Swift compiler synthesizes init(from decoder: Decoder) and encode(to encoder: Encoder) implementations at compile time when all stored properties are themselves Codable, requiring no runtime reflection. This compile-time synthesis is what makes Swift Codable significantly faster than Objective-C NSJSONSerialization, which performs runtime key-value coding inspection. Types that cannot use synthesis (due to computed properties, heterogeneous values, or complex nesting) implement the protocol methods manually.
CodingKeys
A nested enum conforming to String, CodingKey that maps Swift property names to their corresponding JSON key strings. When defined inside a Codable type, it overrides the default behavior of using the Swift property name as the JSON key. Every property intended to be encoded or decoded must appear in CodingKeys — omitting a property silently excludes it from serialization. CodingKeys are used by both the synthesized and manually implemented init(from:) and encode(to:) methods. Subclasses cannot inherit a superclass's CodingKeys and must redeclare all keys including those from the parent class.
keyDecodingStrategy
A JSONDecoder property (of type JSONDecoder.KeyDecodingStrategy) that applies a transformation to JSON keys before matching them to Swift property names. The most useful value is .convertFromSnakeCase, which converts snake_case JSON keys to camelCase Swift names by splitting on underscores and capitalizing the first letter of each subsequent segment. The corresponding JSONEncoder property is keyEncodingStrategy with .convertToSnakeCase. A custom strategy can be provided via .custom([CodingKey] -> CodingKey) for non-standard naming conventions such as kebab-case or PascalCase JSON keys.
DecodingError
A Swift enum (conforming to Error) thrown by JSONDecoder.decode() when JSON cannot be decoded into the target type. It has four cases: .typeMismatch(Any.Type, Context) when a JSON value has the wrong type (e.g., a string where an integer is expected); .valueNotFound(Any.Type, Context) when a non-optional Swift property receives a JSON null; .keyNotFound(CodingKey, Context) when a required key is absent from the JSON object; and .dataCorrupted(Context) for malformed JSON, invalid date strings, or other format violations. Each case's Context contains a codingPath array — the sequence of keys and array indexes from the root to the failing field — which is invaluable for debugging decoding failures in deeply nested structures.
AnyCodable
A community-maintained Swift type (most commonly from the AnyCodable-FlightSchool package by Mattt) that wraps an arbitrary value (Any) and conforms to Codable, enabling encoding and decoding of heterogeneous JSON values where the type is unknown at compile time. It implements init(from:) by attempting to decode each JSON scalar type in turn (bool, int, double, string, array, dictionary, null) and encode(to:) by switching on the wrapped value's type. An equivalent pattern that avoids a third-party dependency is a custom JSONValue enum with associated values for each JSON type — this approach provides type safety and pattern matching without depending on Any.
unkeyedContainer
A decoding container (conforming to UnkeyedDecodingContainer) returned by decoder.unkeyedContainer() or container.nestedUnkeyedContainer(forKey:) for decoding JSON arrays. It provides sequential access to array elements via decode(T.self) and decodeIfPresent(T.self), advancing an internal cursor with each call. The isAtEnd property signals when all elements have been consumed. Use unkeyedContainer when the top-level JSON value is an array (instead of an object), or when a keyed field contains a JSON array that requires custom decoding logic per element. The corresponding encoding container is UnkeyedEncodingContainer, obtained via encoder.unkeyedContainer().

FAQ

How do I decode JSON in Swift using Codable?

Conform your struct to Codable, then call JSONDecoder().decode(MyType.self, from: data) where data is a Data object containing the JSON bytes. The compiler synthesizes the decode implementation when all stored properties are themselves Codable. Wrap the call in a do-catch block to handle DecodingError — the four cases are .typeMismatch, .valueNotFound, .keyNotFound, and .dataCorrupted, each carrying a codingPath that identifies exactly which field caused the failure. To decode a JSON string rather than Data, first convert: let data = jsonString.data(using: .utf8)!. JSONDecoder is not thread-safe — create a new instance per decode call in concurrent contexts.

How do I map snake_case JSON keys to camelCase Swift properties?

Set JSONDecoder().keyDecodingStrategy = .convertFromSnakeCase before calling decode(). This automatically maps user_name to userName, created_at to createdAt, and so on — no CodingKeys enum needed for straightforward renaming. The strategy splits on underscores and capitalizes the following letter. For keys with all-caps abbreviations (e.g., xml_parser becomes xmlParser, but XMLParser would not map correctly), define a CodingKeys entry for those specific fields. Use the mirror strategy on the encoder side: JSONEncoder().keyEncodingStrategy = .convertToSnakeCase for round-tripping data back to a snake_case API.

How do I handle optional JSON fields in Swift?

Declare the Swift property as Optional (e.g., var nickname: String?). With synthesized Codable, Swift treats optional properties leniently — both a missing key and a JSON null value result in the property being set to nil. In a custom init(from:), use container.decodeIfPresent(String.self, forKey: .nickname) which returns nil for both missing keys and JSON null without throwing. To provide a default value for a missing field, combine decodeIfPresent with nil-coalescing: nickname = try c.decodeIfPresent(String.self, forKey: .nickname) ?? "Guest". Synthesized Codable does not support property-level default values for missing keys — a custom init(from:) is required for defaults.

What is the difference between decode() and decodeIfPresent() in Swift?

decode(T.self, forKey:) requires the key to be present and the value to be non-null — it throws DecodingError.keyNotFound for absent keys and DecodingError.valueNotFound for null values when T is non-optional. decodeIfPresent(T.self, forKey:) returns nil for both missing keys and JSON null values without throwing — it is the idiomatic choice for optional fields. A third variant, decode(T?.self, forKey:), returns nil for JSON null but throws .keyNotFound for absent keys — useful when you need to distinguish between an explicitly null value and a missing key. Use decode() for required fields, decodeIfPresent() for optional fields, and decode(Optional.self) only when the null/missing distinction matters.

How do I decode JSON dates in Swift?

Set JSONDecoder().dateDecodingStrategy before calling decode(). For ISO 8601 strings without fractional seconds (e.g., "2026-05-20T10:00:00Z"), use .iso8601. For Unix timestamps, use .secondsSince1970 (seconds as a JSON number) or .millisecondsSince1970 (milliseconds). For ISO 8601 with fractional seconds (e.g., "2026-05-20T10:00:00.000Z"), use .formatted with a DateFormatter configured with dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" and locale = Locale(identifier: "en_US_POSIX"). For any other format, use .custom with a closure that accepts a Decoder and returns a Date. A mismatched strategy throws DecodingError.dataCorrupted.

How do I decode nested JSON objects in Swift?

For nested objects that map to separate Swift structs, define nested Codable structs — the synthesized decoder handles the nesting recursively with no extra code. For flattening a nested JSON object into a parent struct (e.g., promoting {"meta":{"id":1}} fields into the parent), implement a custom init(from:) and use container.nestedContainer(keyedBy: MetaKeys.self, forKey: .meta) to obtain a sub-container for the nested object, then decode its fields individually. For nested JSON arrays, use container.nestedUnkeyedContainer(forKey:) and iterate with isAtEnd. Nesting depth is unlimited — each level of nesting corresponds to one nestedContainer call.

Is JSONDecoder thread-safe in Swift?

No — JSONDecoder is NOT thread-safe. Calling decode() concurrently on the same JSONDecoder instance from multiple threads or async tasks causes data races and undefined behavior. The correct pattern is to create a new JSONDecoder() instance inside each decode call site, or to create one per async Task. If you have a helper that configures a decoder with specific strategies, make it a factory function that returns a fresh instance rather than a shared property. JSONEncoder, by contrast, became thread-safe in Swift 5.3 — a single configured JSONEncoder can be safely shared and called concurrently. The performance overhead of allocating a new JSONDecoder is negligible (microseconds) compared to the actual JSON parsing cost.

How do I call a JSON API in Swift with URLSession?

Use URLSession.shared.data(from: url) with async/await (iOS 15+ / macOS 12+): let (data, response) = try await URLSession.shared.data(from: url). Cast the response to HTTPURLResponse and check the status code: guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { throw URLError(.badServerResponse) }. Then decode: let result = try JSONDecoder().decode(MyType.self, from: data). For POST requests, create a URLRequest, set httpMethod = "POST", set the Content-Type: application/json header, and assign httpBody = try JSONEncoder().encode(payload). For 4xx/5xx responses, attempt to decode an error response model from the response body before falling back to a generic error. For pre-iOS 15 targets, wrap dataTask(with:completionHandler:) in withCheckedThrowingContinuation.

Further reading and primary sources