Networking with URLSession & Codable
Most apps talk to a server: a train app fetches running status, a shop app loads products, a college app downloads the timetable. The data almost always travels as JSON, a text format of objects, arrays, strings, numbers and true/false values.
Swift makes this pleasant with two tools. Codable converts between JSON and your own structs with almost no code, and URLSession downloads data using the async/await you learned in async/await & Tasks. In this lesson you will decode and encode JSON, load data in a SwiftUI screen with loading and error states, and send data to a server.
Decoding JSON with Codable
Make a struct whose property names and types match the JSON keys, and declare it Codable. Then JSONDecoder does the conversion. Codable is really two protocols together: Decodable (JSON to Swift) and Encodable (Swift to JSON).
import Foundation
struct Dish: Codable {
let name: String
let price: Int
let isVeg: Bool
}
let json = """
[
{"name": "Idli", "price": 40, "isVeg": true},
{"name": "Egg curry", "price": 90, "isVeg": false}
]
"""
do {
let dishes = try JSONDecoder().decode([Dish].self, from: Data(json.utf8))
for dish in dishes {
print("\(dish.name): ₹\(dish.price)\(dish.isVeg ? " (veg)" : "")")
}
} catch {
print("Could not read menu: \(error)")
}Idli: ₹40 (veg) Egg curry: ₹90
decode takes the type you want, here [Dish].self for an array of dishes, and the raw bytes as Data. It throws if the JSON does not match: a missing key, a string where a number was expected, or broken JSON. The error message names the problem key, which makes debugging much easier than hand-written parsing.
When the JSON does not match your names
Servers often use snake_case keys or short names. Set the decoder's keyDecodingStrategy to .convertFromSnakeCase, and use a CodingKeys enum to rename any other key. Make a property optional if the key may be missing.
import Foundation
struct TrainStatus: Codable {
let trainNumber: Int
let currentStation: String
let delayMinutes: Int?
let platform: String
enum CodingKeys: String, CodingKey {
case trainNumber, currentStation, delayMinutes
case platform = "pf"
}
}
let json = #"{"train_number": 22001, "current_station": "Sonipat", "pf": "2"}"#
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
do {
let status = try decoder.decode(TrainStatus.self, from: Data(json.utf8))
print(status.trainNumber, status.currentStation, status.platform)
print("Delay: \(status.delayMinutes ?? 0) minutes")
} catch {
print(error)
}22001 Sonipat 2 Delay: 0 minutes
The strategy turns train_number into trainNumber before matching, and CodingKeys maps the short pf key to platform. Because delayMinutes is optional, its absence gives nil instead of an error. For dates, set decoder.dateDecodingStrategy = .iso8601 when the server sends dates like "2026-11-01T09:30:00Z". The raw string #"..."# lets the JSON keep its quotes without escaping.
Encoding Swift values to JSON
JSONEncoder goes the other way. Set outputFormatting to .sortedKeys for a predictable key order, or .prettyPrinted for readable output while debugging.
import Foundation
struct Order: Codable {
let item: String
let quantity: Int
}
do {
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
let data = try encoder.encode(Order(item: "Masala chai", quantity: 2))
print(String(decoding: data, as: UTF8.self))
} catch {
print(error)
}{"item":"Masala chai","quantity":2}The same Order type could be decoded again from this text. Codable also works for saving small amounts of data to files, though for real app data you will use SwiftData in the next lesson.
Fetching data with URLSession
URLSession.shared.data(from:) downloads the contents of a URL and returns the data with a response. Check the HTTP status code (200 to 299 means success) before decoding. In SwiftUI, start loading with the .task modifier, which runs when the view appears and is cancelled automatically if the view disappears.
import SwiftUI
struct Todo: Codable, Identifiable {
let id: Int
let title: String
let completed: Bool
}
enum LoadError: Error {
case badStatus(Int)
}
func fetchTodos() async throws -> [Todo] {
let url = URL(string: "https://jsonplaceholder.typicode.com/todos?_limit=5")!
let (data, response) = try await URLSession.shared.data(from: url)
if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
throw LoadError.badStatus(http.statusCode)
}
return try JSONDecoder().decode([Todo].self, from: data)
}
struct TodoListView: View {
@State private var todos: [Todo] = []
@State private var errorMessage: String?
@State private var isLoading = false
var body: some View {
NavigationStack {
List(todos) { todo in
Label(todo.title, systemImage: todo.completed ? "checkmark.circle.fill" : "circle")
}
.overlay {
if isLoading {
ProgressView("Loading...")
} else if let errorMessage {
ContentUnavailableView("Could not load", systemImage: "wifi.slash",
description: Text(errorMessage))
}
}
.navigationTitle("Tasks")
.task { await load() }
.refreshable { await load() }
}
}
func load() async {
isLoading = todos.isEmpty
defer { isLoading = false }
do {
todos = try await fetchTodos()
errorMessage = nil
} catch {
errorMessage = error.localizedDescription
}
}
}When the screen opens, a spinning indicator with "Loading..." appears in the middle. A moment later it is replaced by a list of five tasks from the free JSONPlaceholder test API, each with an empty circle or a filled tick depending on completed. Pulling the list down shows the refresh spinner and loads again (.refreshable). With the phone in flight mode, the screen instead shows a Wi-Fi-off icon, "Could not load" and the system's error message. The force unwrap in URL(string:)! is acceptable here because the address is a fixed string you typed and tested.
Sending data with a POST request
To send data, build a URLRequest, set its method, headers and body, and call data(for:).
func placeOrder(_ order: Order) async throws -> Int {
var request = URLRequest(url: URL(string: "https://jsonplaceholder.typicode.com/posts")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(order)
let (_, response) = try await URLSession.shared.data(for: request)
return (response as? HTTPURLResponse)?.statusCode ?? 0
}Calling try await placeOrder(Order(item: "Samosa", quantity: 4)) sends the JSON to the test server, which pretends to save it and answers with status 201 ("Created"). Real APIs usually also need an authentication header; never hard-code secret keys inside an app, because anyone can extract them from the app file.
Common mistakes
- Property names that do not match JSON keys. Use
keyDecodingStrategyorCodingKeys. - Non-optional properties for keys that can be missing. Decoding then fails for the whole response.
- Ignoring the HTTP status code. A 404 page is still "data"; check the status before decoding.
- Loading in
bodyorinit. Use.task { }so loading starts once and is cancelled correctly.
Key points
Codablestructs plusJSONDecoder/JSONEncoderconvert JSON with little code..convertFromSnakeCase,CodingKeysand optional properties handle mismatched or missing keys.try await URLSession.shared.data(from:)downloads data; checkHTTPURLResponse.statusCode.- In SwiftUI, load with
.task, showProgressViewwhile loading, and offer.refreshable. URLRequestwithhttpMethod, headers andhttpBodysends data; use HTTPS only.
Exercise
A weather server for Pune returns {"city": "Pune", "temp_c": 31.5, "humidity": 62, "alerts": ["Heat advisory"]}. Write a Codable struct for it using .convertFromSnakeCase, decode the JSON from a string, and print the city, temperature and the number of alerts. Make alerts optional so that responses without alerts also decode.
Show solution
import Foundation
struct Weather: Codable {
let city: String
let tempC: Double
let humidity: Int
let alerts: [String]?
}
let json = #"{"city": "Pune", "temp_c": 31.5, "humidity": 62, "alerts": ["Heat advisory"]}"#
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
do {
let weather = try decoder.decode(Weather.self, from: Data(json.utf8))
print(weather.city, weather.tempC)
print("Alerts: \(weather.alerts?.count ?? 0)")
} catch {
print(error)
}Pune 31.5 Alerts: 1
temp_c becomes tempC through the decoding strategy. Because alerts is optional, a response without that key decodes with alerts set to nil, and ?? 0 prints zero alerts.
Quick Quiz
1. What does Codable combine?
Codable is a type alias for both conversion protocols.
2. A JSON key may be missing. How should the matching property be declared?
Optional properties decode to nil when the key is absent.
3. Where should a SwiftUI view start an async download?
.task starts when the view appears and is cancelled when it disappears.
4. Why should apps use https:// URLs?
HTTPS encrypts traffic, and iOS requires it unless you add an exception.
Learn Swift with a mentor
Free lessons get you started. Our Mobile App Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
