Skip to content
All essays

Mobile architecture

Swift 6 accepts this JSON decoder, then it crashes

Swift 6 rejects one JSON decoding call and accepts the same call on an array of the same model. The accepted version crashes at runtime, and the cause is a compiler check that stops at the first conformance it likes.

By Mike Salari. Staff mobile engineer · 500+ technical interviews from the hiring side.

7 min read

Two lines, same model, same function. The Swift 6 compiler rejects the first and accepts the second.

Swift
try JSONDecoder().decode(User.self, from: data)     // error
try JSONDecoder().decode([User].self, from: data)   // compiles

These two lines are a fragment. The complete file is further down. Both run off the main actor. Both use a Decodable conformance that belongs to the main actor. The first is caught. The second compiles with no warning, and in the program further down it crashes on every run.

This article shows the reproduction, the cause in the compiler source, and a fix that has been waiting for review since September. Everything here was run on 12 November 2026 with Swift 6.4.2 from Xcode 27.2, the main development snapshot of 1 November 2026, and a compiler I built from main at commit 248b7ce6.

The configuration is the Xcode default

I read the project templates that ship inside Xcode 27.2. The app template sets SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor, and the base project settings set SWIFT_APPROACHABLE_CONCURRENCY = YES. A new app project starts with both.

Default main actor isolation comes from SE-0466. It means a type you write without any annotation is isolated to the main actor. That includes its protocol conformances, which become isolated conformances as defined in SE-0470.

So in a new project, this struct has a Decodable conformance that is only safe to use on the main actor:

Swift
struct User: Codable { var name: String }

SE-0470 has a rule for that: an isolated conformance can only be used inside its own isolation domain. The compiler enforces it with this error: main actor-isolated conformance of 'User' to 'Decodable' cannot be used in @concurrent context.

That error is the check doing its job. The problem is how easy it is to walk past it.

The check passes when the model is inside a collection

This file compiles four functions with swiftc -swift-version 6 -default-isolation MainActor.

Swift
import Foundation

struct User: Codable { var name: String }
struct Point: Comparable {
    var x = 0
    static func < (a: Point, b: Point) -> Bool { a.x < b.x }
}

@concurrent func one(_ d: Data) async throws -> User {
    try JSONDecoder().decode(User.self, from: d)
}
@concurrent func many(_ d: Data) async throws -> [User] {
    try JSONDecoder().decode([User].self, from: d)
}
@concurrent func sort(_ ps: [Point]) async -> [Point] {
    ps.sorted()
}
@concurrent func find(_ ps: [Point], _ p: Point) async -> Bool {
    ps.contains(p)
}
FunctionSwift 6.4.2Snapshot, 1 Nov 2026
one: decode(User.self, ...)errorerror
many: decode([User].self, ...)acceptedaccepted
sort: ps.sorted()acceptedaccepted
find: ps.contains(p)acceptedaccepted

I tested more shapes with the same result. Decoding [String: User] and User? is accepted. Encoding [User] is accepted while encoding User is an error. min(), max() and firstIndex(of:) are accepted. Comparing two [Tag]? values with == is accepted. Set built from [[Tag]] is accepted, while Set built from [Tag] is an error.

What it does at runtime

A missing error only matters if something can go wrong. Here is a program where it does. It uses no unsafe code, no @unchecked Sendable and no nonisolated(unsafe).

Swift
// Build: swiftc -swift-version 6 -default-isolation MainActor -strict-memory-safety main.swift
// No unsafe code, no @unchecked Sendable, no nonisolated(unsafe).
import Foundation

struct User: Codable {
    static var seen: [String: Int] = [:]      // main actor-isolated by default

    var name: String

    init(from decoder: any Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        name = try c.decode(String.self, forKey: .name)
        User.seen[name, default: 0] += 1      // legal: this initializer is main actor-isolated
    }
}

@concurrent func parse(_ data: Data) async throws -> [User] {
    try JSONDecoder().decode([User].self, from: data)
    // try JSONDecoder().decode(User.self, from: data)   // this line is rejected by the compiler
}

let json = Data(("[" + (0..<5_000).map { "{\"name\":\"user\($0)\"}" }.joined(separator: ",") + "]").utf8)

let tasks = (0..<8).map { _ in Task { try await parse(json).count } }
var total = 0
for t in tasks { total += try await t.value }
print("decoded \(total) users, cache has \(User.seen.count) names")

The custom init(from:) writes to a static dictionary. That is legal, because the initialiser and the dictionary are both on the main actor. Eight tasks then decode the same JSON in a @concurrent function, so eight threads write to one dictionary.

Results:

  • Swift 6.4.2, Swift 6 mode, with -strict-memory-safety -warnings-as-errors: no diagnostics. Crashed on 5 runs out of 5, with SIGSEGV or SIGABRT.
  • Snapshot of 1 November 2026, same flags: no diagnostics. Crashed on 3 runs out of 3.
  • Swift 6.4.2, Swift 5 mode with default main actor isolation -strict-concurrency=complete, and the upcoming features InferIsolatedConformances and NonisolatedNonsendingByDefault: no diagnostics. Crashed on 3 runs out of 3. I chose these flags to approximate the Xcode template. I did not build an Xcode project.
  • ThreadSanitizer on 6.4.2: reports a Swift access race in User.init(from:).
  • Control: remove @concurrent so the decode runs on the main actor, and the program prints decoded 40000 users, cache has 5000 names and exits normally.

A version with sorted() behaves the same way. A < operator that appends to a main actor array, called through sorted() from eight detached tasks, compiles clean and crashes on every run.

The cause: the check stops at the first conformance it likes

The error comes from one place, MismatchedIsolatedConformances in lib/Sema/TypeCheckConcurrency.cpp. It is a callback handed to a helper named forEachConformance, which walks every conformance a call depends on.

The helper has a documented contract in TypeCheckProtocol.h: when the callback returns true, the walk stops. The callback returns true when it meets a conformance that is fine. So the first acceptable conformance ends the walk, and anything after it is never looked at.

Now the table makes sense. decode(User.self) depends on one conformance, User: Decodable, and it is checked. decode([User].self) depends on [User]: Decodable, which comes from Array and is not isolated. The walk sees that one first, accepts it, and stops before it reaches the User: Decodable inside. sorted() needs [Point]: Sequence and then Point: Comparable, in that order.

If that reading is right, the order of conformances should decide whether you get an error. It does:

Swift
protocol P { func run() }

struct Iso: @MainActor P {
    @MainActor func run() {}
}
struct Plain: P {
    func run() {}
}

nonisolated func call<T: P, U: P>(_ t: T, _ u: U) {
    t.run()
    u.run()
}

nonisolated func first() {
    call(Iso(), Plain())
}

nonisolated func second() {
    call(Plain(), Iso())
}

On Swift 6.4.2 and on the snapshot, first is an error and second compiles. The only difference is which argument comes first.

A fix exists and has not been reviewed

I am not the first to find this line. Pull request 92043, opened by Dev10-sys on 7 September 2026, changes the callback to keep walking and describes the same cause. It was written for issue 92004, which is about functions with two generic parameters. A comment on 9 September asked for a review. On 12 November it had none.

Its tests cover functions with two generic parameters. They do not cover standard library calls, collections of models, or protocol extensions. So I applied its diff to main at 248b7ce6, built the compiler, and ran my files against it.

  • With the diff: all 22 of my test files are rejected, including the crashing JSON program. So are many, sort and find above, and second in the ordering example.
  • Same build with the diff reverted: 15 of the 22 compile with no diagnostics, matching 6.4.2 and the snapshot.
  • Valid code: the two on-main control programs still compile with no diagnostics and run normally with the diff applied.

I posted these results as a comment on the pull request.

The diff also rejects the code from issue 87993, reported by FiggChristian in March 2026 and still labelled as needing triage. That issue shows the same escape through a protocol extension with a constrained associated type, and a comment from jamieQ shows it through a protocol composition. Both reproductions compile on 6.4.2 and on the snapshot, and both are rejected with the diff.

An earlier fix in the same area, pull request 88030, was merged in March 2026. The cases in this article still reproduce after it.

How exposed a real app is

The gap in the check is wide. The number of apps with a real bug behind it is smaller, and I cannot measure it.

For a data race, three things have to be true together. The project uses default main actor isolation, or writes isolated conformances by hand. A model has a hand-written init(from:), encode(to:), ==, < or hash(into:) that reads or writes mutable main actor state. And that model is used off the main actor through a collection, an optional, or a standard library algorithm.

A synthesised Codable conformance touches no shared state, so most models are not affected in practice. The ones at risk are models with custom decoding that fill a cache, bump a counter, or register themselves somewhere.

For scale on the first condition, a GitHub code search on 12 November 2026 returned about 33,600 .pbxproj files containing SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor and 954 Package.swift files containing defaultIsolation(MainActor.self). Those are files, not apps. The count includes forks and copies, and it is GitHub's estimate.

What to do today

Mark plain data models nonisolated. The conformance is then usable from any isolation, and both forms of the call compile:

Swift
import Foundation

nonisolated struct User: Codable {
    var name: String
}

@concurrent func parseOne(_ data: Data) async throws -> User {
    try JSONDecoder().decode(User.self, from: data)
}

@concurrent func parseMany(_ data: Data) async throws -> [User] {
    try JSONDecoder().decode([User].self, from: data)
}

This also moves the error to where it belongs. When I made the crashing model nonisolated and kept the write to a main actor dictionary inside init(from:), the compiler rejected that write on all three compilers: main actor-isolated var 'seen' can not be mutated from a nonisolated context.

Run your tests with ThreadSanitizer. It caught the race here when the compiler did not.

A clean Swift 6 build is still strong evidence that your code has no data races. This is one documented case where it is wrong.

What I did not verify

  • I did not run the compiler test suite against the pull request, so I cannot say it is free of regressions.
  • I tested two valid programs for false errors. That is a small sample.
  • I did not investigate exploitability, and I am not calling this a vulnerability.
  • Everything ran on macOS on Apple silicon. I did not test Linux or Windows.
  • I did not build an Xcode project. The template settings come from reading the template files, and the Swift 5 mode result comes from swiftc flags.

The interview version

If I were asking about this in a senior iOS interview, the question would be short: under default main actor isolation, what isolation does a Codable conformance get, and what does that mean for decoding on a background task. A good answer names the isolated conformance and reaches for nonisolated on the model. A stronger one adds that the compiler's check for it has a known gap, and says how they would catch the race anyway.

Sources

Use the practical guide

Related resources

Free resources that put this essay to work: short enough to use this week, and each one works on its own, with or without a book.

Share this essay