Guide, 9 min read, updated 30 September 2026

Fetching and decoding JSON in Swift with Codable and async/await

The clean, modern way to bring API data into an iPhone app, with no third-party libraries.

SwiftiOS
All guides and cheat sheets

Most apps are windows onto data that lives somewhere else. Swift's Codable protocol and async/await concurrency make fetching and decoding that data concise and type-safe, with no third-party libraries needed.

The JSON we want to decode

JSON
{
  "order_id": 1042,
  "customer_name": "Maria Rossi",
  "created_at": "2026-09-28T09:15:00Z",
  "total": 38.5,
  "items": [
    { "sku": "FW-01", "name": "Flat white", "qty": 2 },
    { "sku": "CR-02", "name": "Almond croissant", "qty": 1 }
  ]
}

Model it with Codable

Swift
import Foundation

struct Order: Codable, Identifiable {
    let orderId: Int
    let customerName: String
    let createdAt: Date
    let total: Decimal
    let items: [Item]

    var id: Int { orderId }

    struct Item: Codable, Hashable {
        let sku: String
        let name: String
        let qty: Int
    }
}

Properties use Swift naming; the decoder converts snake_case keys for you. Decimal avoids the rounding errors that Double can introduce with money.

Configure the decoder

Swift
let decoder: JSONDecoder = {
    let d = JSONDecoder()
    d.keyDecodingStrategy = .convertFromSnakeCase
    d.dateDecodingStrategy = .iso8601
    return d
}()

Custom keys when names do not match

Swift
struct Product: Codable {
    let id: String
    let displayName: String
    let price: Decimal

    enum CodingKeys: String, CodingKey {
        case id = "sku"
        case displayName = "title"
        case price
    }
}

Fetch with async/await

Swift
enum APIError: Error {
    case badStatus(Int)
}

struct OrdersClient {
    let baseURL = URL(string: "https://api.example.com")!

    func recentOrders() async throws -> [Order] {
        let url = baseURL.appending(path: "orders/recent")
        let (data, response) = try await URLSession.shared.data(from: url)

        guard let http = response as? HTTPURLResponse else { return [] }
        guard (200..<300).contains(http.statusCode) else {
            throw APIError.badStatus(http.statusCode)
        }
        return try decoder.decode([Order].self, from: data)
    }
}

Show it in SwiftUI

Swift
import SwiftUI

struct OrdersView: View {
    @State private var orders: [Order] = []
    @State private var errorMessage: String?

    var body: some View {
        NavigationStack {
            List(orders) { order in
                VStack(alignment: .leading) {
                    Text(order.customerName).font(.headline)
                    Text(order.total, format: .currency(code: "GBP"))
                        .foregroundStyle(.secondary)
                }
            }
            .navigationTitle("Recent orders")
            .overlay {
                if let errorMessage {
                    ContentUnavailableView("Could not load orders",
                                           systemImage: "wifi.exclamationmark",
                                           description: Text(errorMessage))
                }
            }
            .task { await load() }
            .refreshable { await load() }
        }
    }

    private func load() async {
        do {
            orders = try await OrdersClient().recentOrders()
            errorMessage = nil
        } catch {
            errorMessage = error.localizedDescription
        }
    }
}

.task starts the request when the view appears and cancels it automatically if the view disappears. .refreshable adds pull-to-refresh for free.

Debugging decoding errors

When decoding fails, print the full error. DecodingError tells you exactly which key or type did not match.

Swift
do {
    let order = try decoder.decode(Order.self, from: data)
    print(order)
} catch let DecodingError.keyNotFound(key, context) {
    print("Missing key:", key.stringValue, "at", context.codingPath.map(\.stringValue))
} catch let DecodingError.typeMismatch(type, context) {
    print("Type mismatch for", type, "at", context.codingPath.map(\.stringValue))
} catch {
    print(error)
}

Keep the Swift cheat sheet nearby for syntax reminders, and see what is new in Swift 6.2 for the latest concurrency changes.

Written by Alessandro Ecclesie Agazzi, freelance analytics engineer in London. Updated 30 September 2026.