How to Parse Deeply Nested JSON Using Codable in Swift

Master the art of decoding complex, deeply nested JSON structures in Swift using Codable, custom CodingKeys, nested containers, and real-world strategies that keep your code clean and maintainable.

Why Deeply Nested JSON Is a Common Challenge in Swift

Almost every modern iOS app communicates with a REST API that returns JSON data. While Swift's Codable protocol makes simple JSON decoding effortless, real-world APIs rarely return flat, tidy structures.

Third-party APIs from providers like Stripe, Twilio, and Google Maps routinely nest data three, four, or even five levels deep.

A typical response might wrap the actual payload inside a 'data' object, which itself contains an array of items, each holding a nested 'attributes' dictionary with further sub-objects.

When developers encounter these structures for the first time, the instinct is often to reach for manual JSONSerialization parsing with dictionaries and type casts. But this throws away the type safety, compile-time checking, and automatic synthesis that make Codable so powerful.

The good news is that Codable handles arbitrarily nested JSON without any third-party libraries. You just need three core techniques: custom CodingKeys, nested keyed containers, and intermediate 'throwaway' structs.

This guide covers all three with production-ready examples. If you are building your first iOS app, our iOS app development complete guide (/blog/ios-app-development-complete-guide) covers the broader networking stack.

Quick Review — How Codable Works Under the Hood

When you write struct User: Codable, Swift automatically synthesises an init(from decoder: Decoder) method and an encode(to encoder: Encoder) method.

The decoder provides a container — a type-safe view over the raw JSON — keyed by a CodingKeys enum that maps Swift property names to JSON key strings.

For a flat JSON object like {"user_name": "Alice", "age": 30}, the compiler generates a CodingKeys enum with cases userName and age, maps them to the JSON strings, and calls container.decode for each property.

When the JSON is nested, automatic synthesis cannot bridge across nesting levels. You must write a custom init(from:) that manually requests nested containers.

The key method is container.nestedContainer(keyedBy:forKey:), which returns a new container scoped to a child JSON object. You can chain these calls to reach any depth.

Apple's official Swift documentation at swift.org/documentation covers the Codable protocol in detail. The Swift Evolution proposal SE-0166 (github.com/swiftlang/swift-evolution/blob/main/proposals/0166-swift-archival-serialization.md) provides the original design rationale.

Technique 1 — Custom CodingKeys for Renamed and Nested Properties

The simplest nesting scenario involves a JSON response where the data you need sits one level deeper than your model.

Consider an API that returns {"status": "ok", "data": {"id": 1, "name": "Alice"}}. You want a User struct with just id and name, ignoring the wrapper.

Here is how to decode it using nested containers:

 // User.swift
struct User: Codable {
    let id: Int
    let name: String
    
    enum CodingKeys: String, CodingKey {
        case data
    }
    
    enum DataKeys: String, CodingKey {
        case id, name
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(
            keyedBy: CodingKeys.self
        )
        let data = try container.nestedContainer(
            keyedBy: DataKeys.self,
            forKey: .data
        )
        id = try data.decode(Int.self, forKey: .id)
        name = try data.decode(String.self, forKey: .name)
    }
}

This keeps your public model API clean — callers see User(id: 1, name: "Alice") with no awareness of the JSON wrapper.

One common mistake is forgetting to define separate CodingKeys enums for each nesting level. Each container needs its own key type, or the compiler cannot resolve the key paths.

This technique scales to any number of properties and works with optional nested objects by using decodeIfPresent.

Technique 2 — Nested Containers for Multi-Level JSON

When JSON nests three or more levels deep, chaining nestedContainer calls becomes verbose but remains fully type-safe.

Suppose an API returns {"response": {"results": {"users": [{"profile": {"first_name": "Alice"}}]}}}. To decode this into a flat [User] array, your init(from:) chains four container calls: root → response → results → users.

At each level, define a private CodingKeys enum listing the keys for that level. The final container.decode([User].self, forKey: .users) call leverages automatic Codable synthesis for the User struct itself.

You only write manual decoding for the wrapper levels. This layered approach is sometimes called the 'onion peeling' pattern in the Swift community.

A cleaner alternative for extremely deep nesting is to define small intermediate structs — ResponseWrapper, ResultsWrapper — that each decode one level and expose the inner payload.

While this creates more types, each type is trivially simple and the compiler handles all the decoding automatically.

Choose the manual-container approach when you want zero extra types. Choose the wrapper-struct approach when readability and maintainability matter more.

Technique 3 — Handling Arrays, Nulls, and Mixed Types in Nested JSON

Real-world nested JSON introduces additional challenges beyond simple key traversal.

Arrays of heterogeneous objects require you to use an unkeyedContainer and loop through elements, decoding each one individually. This is essential when the JSON array contains objects of different shapes — for example, a feed API that mixes text posts, image posts, and video posts.

Use container.decode(String.self, forKey: .type) to peek at a discriminator field, then decode the appropriate struct based on its value.

Null handling is another pain point. Use decodeIfPresent for optional fields and provide sensible defaults using the nil-coalescing operator.

For APIs that return the same field as either a String or an Int (surprisingly common), define a custom FlexibleValue enum with cases for each type and a manual init(from:) that tries each decode in sequence.

The Swift standard library does not include a built-in AnyCodable type, but implementing one is roughly twenty lines of code and eliminates dozens of potential crashes.

The Swift Forums (forums.swift.org) contain extensive community discussion on these patterns, with reusable code snippets battle-tested across thousands of apps.

Real-World Example — Parsing a Stripe API Response

Let us walk through a realistic example. The Stripe API's charge object returns deeply nested JSON including billing_details with a nested address object and a metadata dictionary.

Here is a complete Codable model that flattens the nested structure:

 // StripeCharge.swift
struct StripeCharge: Codable {
    let id: String
    let amount: Int
    let currency: String
    let city: String
    let country: String
    let orderId: String?
    
    enum TopKeys: String, CodingKey {
        case id, amount, currency
        case billingDetails = "billing_details"
        case metadata
    }
    
    enum BillingKeys: String, CodingKey {
        case address
    }
    
    enum AddressKeys: String, CodingKey {
        case city, country
    }
    
    init(from decoder: Decoder) throws {
        let top = try decoder.container(keyedBy: TopKeys.self)
        id = try top.decode(String.self, forKey: .id)
        amount = try top.decode(Int.self, forKey: .amount)
        currency = try top.decode(String.self, forKey: .currency)
        
        let billing = try top.nestedContainer(
            keyedBy: BillingKeys.self,
            forKey: .billingDetails
        )
        let address = try billing.nestedContainer(
            keyedBy: AddressKeys.self,
            forKey: .address
        )
        city = try address.decode(String.self, forKey: .city)
        country = try address.decode(String.self, forKey: .country)
        
        let meta = try top.decode(
            [String: String].self,
            forKey: .metadata
        )
        orderId = meta["order_id"]
    }
}

This flattened model is far easier to work with in your app's business logic than navigating a nested dictionary at every call site.

Unit-test this decoder by loading a sample JSON file from your test bundle. Decode it and assert each property matches the expected value.

If you are building a payment integration, our feature page on subscription app development (/features/subscription-app-development) covers the full Stripe integration flow.

Performance Considerations for Large JSON Payloads

When parsing large JSON responses — for example, a paginated API returning hundreds of deeply nested objects — performance becomes a concern.

Swift's JSONDecoder is built on Foundation's NSJSONSerialization, which loads the entire JSON into memory before Codable decoding begins. For payloads exceeding a few megabytes, this can cause noticeable latency and memory spikes.

Several strategies mitigate this. First, request only the fields you need using API query parameters or GraphQL field selection — reducing payload size at the source.

Second, perform decoding on a background thread using Swift's structured concurrency:

let charges = try await Task.detached { try JSONDecoder().decode([StripeCharge].self, from: data) }.value

Third, for extremely large datasets, consider streaming parsers like the open-source Swift package swift-extras-json (github.com/swift-extras/swift-extras-json) that processes JSON tokens incrementally without loading the full document.

In most apps, however, standard JSONDecoder performance is more than adequate. Premature optimisation adds unnecessary complexity.

Best Practices and Common Pitfalls

After years of working with Codable in production apps, several best practices emerge.

Always use let (immutable) properties in your Codable structs to enforce value semantics and thread safety.

Prefer Swift's automatic Codable synthesis wherever possible. Only write manual init(from:) for levels that require flattening.

Name your CodingKeys enums descriptively — TopLevelKeys, DataKeys, ProfileKeys — so that other developers can follow the nesting hierarchy at a glance.

Add keyDecodingStrategy = .convertFromSnakeCase to your JSONDecoder to avoid writing CodingKeys purely for snake_case conversion.

Write at least one unit test per Codable model using real (anonymised) API response fixtures stored as JSON files in your test bundle.

When an API returns errors in a different JSON shape than success responses, use a Result type or a try/catch at the decode call site.

For more Swift best practices, read our article on Swift development best practices (/blog/swift-development-best-practices). Need help building a robust networking layer? Explore our Swift app development service (/swift-app-development).

Frequently Asked Questions

Can Swift Codable handle arbitrarily deep JSON nesting?

Yes. By chaining nestedContainer(keyedBy:forKey:) calls in a custom init(from: Decoder), you can decode JSON nested to any depth. Each nesting level requires its own CodingKeys enum to map the keys at that level.

Do I need third-party libraries to parse complex JSON in Swift?

No. Swift's built-in Codable protocol handles all standard JSON structures including nested objects, arrays, mixed types, and optional fields. Third-party libraries like SwiftyJSON are legacy solutions that predate Codable and are no longer recommended.

How do I handle JSON fields that can be either a String or an Int?

Define a custom enum (e.g., FlexibleValue) that conforms to Codable. In its init(from:), attempt to decode as Int first, then fall back to String. Use this enum as the property type in your model struct.

What is the performance impact of decoding large nested JSON in Swift?

Standard JSONDecoder handles payloads up to a few megabytes with negligible latency. For larger payloads, decode on a background thread using Swift concurrency and consider requesting fewer fields from the API to reduce payload size.