Networking in Swift: Fetching JSON with `URLSession` and `Codable`


WALL-E spent hundreds of years alone on Earth — imagine if he could have fetched data from a server to find out where the humans went. In real apps, networking is how your code talks to the outside world: fetching movie listings, loading user profiles, or pulling down the latest Pixar release dates from an API.

You’ll learn how to make GET requests with URLSession, decode JSON into Swift types using Codable and JSONDecoder, handle networking errors, and display fetched data in a SwiftUI view. We won’t cover advanced topics like POST requests, authentication, or building a full networking layer — those are covered in dedicated posts.

What You’ll Learn

What Is Networking?

Networking is the process of sending and receiving data over the internet. When your app makes a request to a server, the server sends back a response — usually formatted as JSON (JavaScript Object Notation), a lightweight text format that both humans and computers can read.

Think of it like Marlin’s journey across the ocean in Finding Nemo. Marlin (your app) sends out a message: “Where is Nemo?” The ocean current (the internet) carries the message to Sydney (the server), and a response comes back with Nemo’s location (the JSON data).

Here’s what a typical JSON response looks like:

{
  "title": "Finding Nemo",
  "year": 2003,
  "director": "Andrew Stanton"
}

Swift provides two key tools for networking:

  • URLSession — makes HTTP requests and receives responses
  • Codable — converts JSON data into Swift structs and back

URLSession: Making Requests

URLSession is Apple’s built-in API for making network requests. The simplest way to fetch data is with its data(from:) method, which uses Swift’s async/await syntax.

Apple Docs: URLSession — Foundation

let url = URL(string: "https://api.example.com/movies")!
let (data, response) = try await URLSession.shared.data(from: url)

Let’s break this down:

  • URL(string:) creates a URL from a string. The ! force-unwraps it — in production code, you’d handle this more safely.
  • URLSession.shared is a ready-to-use session that handles most common scenarios.
  • .data(from:) makes a GET request and returns two values: the raw Data from the server and the URLResponse metadata.
  • try await means this code can fail (try) and runs asynchronously (await) — the app doesn’t freeze while waiting for the server.

If you haven’t used async/await before, think of it as telling Swift: “Start this task and come back to me when it’s done.” Your app stays responsive while the network request happens in the background. We’ll explore async/await in depth in Introduction to Async/Await.

Codable: Decoding JSON

Raw Data from the server isn’t useful on its own — you need to convert it into Swift types. The Codable protocol makes this automatic.

Apple Docs: Codable — Swift Standard Library

A Codable struct declares properties that match the JSON keys. JSONDecoder handles the conversion.

Apple Docs: JSONDecoder — Foundation

struct Movie: Codable {
    let title: String
    let year: Int
    let director: String
}
let decoder = JSONDecoder()
let movie = try decoder.decode(Movie.self, from: data)
print(movie.title)
Finding Nemo

JSONDecoder reads the JSON data and creates a Movie instance by matching each JSON key ("title", "year", "director") to the struct’s properties. If a key is missing or the type doesn’t match, the decoder throws an error.

Handling Different Key Formats

APIs often use snake_case for JSON keys (release_date), but Swift conventions use camelCase (releaseDate). You can tell the decoder to convert automatically.

struct PixarFilm: Codable {
    let title: String
    let releaseDate: String
    let boxOffice: Int
}

let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
let film = try decoder.decode(PixarFilm.self, from: data)

With .convertFromSnakeCase, a JSON key like "release_date" maps to the Swift property releaseDate automatically. This is how you work with structs and classes to model your API data.

Decoding Arrays

Most APIs return a list of items, not just one. To decode an array, pass [Movie].self to the decoder.

let movies = try decoder.decode([Movie].self, from: data)
print(movies.count)
5

This decodes a JSON array of movie objects into a Swift array of Movie structs.

Putting It Together: Fetch and Decode

Here’s the complete flow — make a request and decode the response — wrapped in an async function.

func fetchMovies() async throws -> [Movie] {
    let url = URL(string: "https://api.example.com/pixar-movies")!
    let (data, _) = try await URLSession.shared.data(from: url)

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

This function:

  1. Creates the URL
  2. Fetches raw data from the server
  3. Decodes the JSON into an array of Movie structs
  4. Returns the result (or throws if anything fails)

The _ ignores the URLResponse since we only need the data for now. In production, you’d check the response’s HTTP status code.

Displaying Remote Data in SwiftUI

To show fetched data in a SwiftUI view, call your async function from a .task modifier. This runs the async work when the view appears and cancels it if the view disappears.

struct MovieListView: View {
    @State private var movies: [Movie] = []
    @State private var isLoading = true

    var body: some View {
        NavigationStack {
            Group {
                if isLoading {
                    ProgressView("Loading movies...")
                } else {
                    List(movies, id: \.title) { movie in
                        VStack(alignment: .leading) {
                            Text(movie.title).font(.headline)
                            Text("Directed by \(movie.director)")
                                .font(.subheadline)
                                .foregroundStyle(.secondary)
                        }
                    }
                }
            }
            .navigationTitle("Pixar Movies")
            .task {
                do {
                    movies = try await fetchMovies()
                } catch {
                    print("Failed to load: \(error)")
                }
                isLoading = false
            }
        }
    }
}

The .task modifier is the SwiftUI way to start async work. While isLoading is true, the user sees a spinner. Once the data arrives, the list populates. If the request fails, we print the error and stop the loading indicator.

Handling Errors

Network requests can fail for many reasons — no internet, server errors, invalid JSON. Using error handling with do-catch lets you respond gracefully.

struct RobustMovieListView: View {
    @State private var movies: [Movie] = []
    @State private var errorMessage: String?
    @State private var isLoading = true

    var body: some View {
        NavigationStack {
            Group {
                if isLoading {
                    ProgressView("Loading...")
                } else if let errorMessage {
                    ContentUnavailableView(
                        "Something Went Wrong",
                        systemImage: "wifi.exclamationmark",
                        description: Text(errorMessage)
                    )
                } else {
                    List(movies, id: \.title) { movie in
                        Text(movie.title)
                    }
                }
            }
            .navigationTitle("Pixar Movies")
            .task { await loadMovies() }
        }
    }

    private func loadMovies() async {
        do {
            movies = try await fetchMovies()
        } catch is URLError {
            errorMessage = "Check your internet connection."
        } catch {
            errorMessage = "Couldn't load movies. Try again later."
        }
        isLoading = false
    }
}

This view handles three states: loading, error, and success. URLError catches network-specific problems like no internet or a timeout, while the generic catch handles everything else — like malformed JSON. ContentUnavailableView is Apple’s built-in view for showing empty or error states.

Checking the HTTP Status Code

The server response includes a status code that tells you whether the request succeeded. A 200 means success, 404 means not found, and 500 means something broke on the server.

func fetchMovies() async throws -> [Movie] {
    let url = URL(string: "https://api.example.com/pixar-movies")!
    let (data, response) = try await URLSession.shared.data(from: url)

    guard let httpResponse = response as? HTTPURLResponse,
          httpResponse.statusCode == 200 else {
        throw URLError(.badServerResponse)
    }

    let decoder = JSONDecoder()
    return try decoder.decode([Movie].self, from: data)
}

We cast the generic URLResponse to HTTPURLResponse to access the statusCode. If it’s not 200, we throw an error instead of trying to decode garbage data.

Common Mistakes

Decoding on the Main Thread Without async/await

Network requests block the main thread if you don’t use async/await. This freezes your UI while waiting for the server.

// ❌ Don't do this — blocks the main thread
let data = try Data(contentsOf: url)
let movies = try JSONDecoder().decode([Movie].self, from: data)
// ✅ Do this — runs asynchronously
let (data, _) = try await URLSession.shared.data(from: url)
let movies = try JSONDecoder().decode([Movie].self, from: data)

Always use URLSession with async/await for network requests. Data(contentsOf:) is meant for local files, not URLs.

Mismatched Codable Property Names

If your Swift property names don’t match the JSON keys and you haven’t set a key decoding strategy, the decoder will throw an error.

// JSON: { "box_office": 940000000 }

// ❌ Don't do this — property doesn't match JSON key
struct Film: Codable {
    let boxOffice: Int  // Expects "boxOffice" in JSON
}
// ✅ Do this — set the decoding strategy
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
let film = try decoder.decode(Film.self, from: data)

Use .convertFromSnakeCase when the API uses snake_case, or define a CodingKeys enum for full control over the mapping.

Forgetting to Handle the Error Case

If you call an async throwing function without a do-catch block, your app will crash on the first network failure.

// ❌ Don't do this — crash on any error
.task {
    movies = try! await fetchMovies()
}
// ✅ Do this — handle errors gracefully
.task {
    do {
        movies = try await fetchMovies()
    } catch {
        errorMessage = error.localizedDescription
    }
}

Never use try! for network code. The internet is unreliable — closures and error handling make your app resilient.

What’s Next?

  • Networking is how your app sends and receives data over the internet
  • URLSession.shared.data(from:) makes GET requests asynchronously
  • Codable structs model your JSON data, and JSONDecoder converts raw data into Swift types
  • .convertFromSnakeCase bridges API naming conventions to Swift style
  • The .task modifier runs async work when a SwiftUI view appears
  • Always handle errors with do-catch — the network is never guaranteed

Ready to dive deeper into asynchronous programming? Head over to Introduction to Async/Await to understand how Swift handles concurrent work under the hood.