Article

Swift 6.4: What Changes in the Code You Maintain

A practical look at Swift 6.4, with before-and-after examples for availability, async cleanup, optional types, borrowing iteration, and test migration.

An orange Swift bird above a dark architectural pedestal, connected by illuminated paths to silver and violet code panels.

When I look at a language release, I want to know what it does for the code someone has to maintain after the release announcement stops being interesting.

Does it make resource ownership clearer? Does it remove another opportunity to forget cleanup? Can a team adopt it without rewriting every test helper at once?

Swift 6.4 has useful answers to those questions. It was released on September 15, 2026, and the improvements extend well beyond Apple application development. Here I want to concentrate on changes you can see in ordinary application and library code. Swift 6.4 release announcement.

These examples use Swift 6.0 as the starting point. They are original examples, with the newer spelling or implementation shown alongside the earlier approach. A few details matter before we start: a compiler version, a language mode, and an operating system deployment target are separate choices. Installing Swift 6.4 does not give an older operating system every new runtime API.

The changes at a glance

ConcernSwift 6.0 approachApproach available in Swift 6.4
Availability across Apple platformsList platform versions individuallyUse anyAppleOS where the release requirements align
Asynchronous cleanupAwait cleanup on each exit path or in a helperAwait it directly inside defer
Cleanup after cancellationSometimes await a separate unstructured taskUse a cancellation shield for a bounded cleanup operation
Optional protocol typesWrite (any Protocol)? or (some Protocol)?Write any Protocol? or some Protocol?
Iteration over noncopyable valuesCustom access patterns or reference wrappersBorrow elements through Iterable
Migrating test assertionsKeep assertion helpers within their test frameworkUse supported cross-framework assertions with the appropriate interop mode
Main-actor defaultsAnnotate the relevant declarationsConfigure a target’s default isolation; this arrived in 6.2, not 6.4

Availability checks that say what you mean

Suppose our application deliberately enables a reporting feature only on the version 26 generation of Apple’s operating systems. This is an application-defined policy, not a claim that the string-returning function below requires a new system API.

Swift 6.0 syntax:

@available(macOS 26, iOS 26, watchOS 26, tvOS 26, visionOS 26, *)
func detailedStatus() -> String {
    "Detailed report enabled"
}

func statusText() -> String {
    if #available(macOS 26, iOS 26, watchOS 26, tvOS 26, visionOS 26, *) {
        return detailedStatus()
    }
    return "Basic report enabled"
}

Swift 6.4:

@available(anyAppleOS 26, *)
func detailedStatus() -> String {
    "Detailed report enabled"
}

func statusText() -> String {
    if #available(anyAppleOS 26, *) {
        return detailedStatus()
    }
    return "Basic report enabled"
}

The newer form reduces the chance of changing one platform’s requirement while overlooking another. anyAppleOS applies to unified Apple OS version numbers starting at 26. A specifically named platform can override it when the requirements differ. Swift compiler changelog, Swift 6.4.

Do not flatten genuinely different deployment requirements merely to shorten an attribute. Also, * does not exclude non-Apple platforms; availability and conditional compilation solve different problems. Swift language reference: availability.

The old example uses the syntax available in 6.0 with today’s chosen deployment policy. It is not suggesting those operating system versions shipped alongside Swift 6.0.

Put asynchronous cleanup beside acquisition

Consider a report session that must be finished before its caller continues. This small actor stands in for the real service and lets us exercise both success and failure:

enum ReportError: Error {
    case unavailable
}

actor ReportSession {
    private(set) var finished = false
    let shouldFail: Bool

    init(shouldFail: Bool = false) {
        self.shouldFail = shouldFail
    }

    func read() throws -> String {
        if shouldFail { throw ReportError.unavailable }
        return "report contents"
    }

    func finish() {
        finished = true
    }
}

Swift 6.0:

func exportReport(using session: ReportSession) async throws -> String {
    do {
        let report = try await session.read()
        await session.finish()
        return report
    } catch {
        await session.finish()
        throw error
    }
}

Swift 6.4:

func exportReport(using session: ReportSession) async throws -> String {
    defer { await session.finish() }
    return try await session.read()
}

Swift already allowed a synchronous defer inside an async function. The change is that the deferred body can now suspend. The enclosing scope waits for it, including when leaving through a thrown error. SE-0493.

The first implementation works. Its maintenance cost appears when another return path is added and someone has to remember the corresponding cleanup. A helper could centralize that logic in 6.0; 6.4 lets the language express it directly.

Wrapping the cleanup in an unawaited Task inside defer would change the ordering: the function could return before cleanup finished. That is a consequential difference when the caller immediately tries to use the resource again.

This example deliberately gives finish() a nonthrowing interface. A throwing cleanup operation needs an explicit error policy inside the deferred body; defer does not create a second error channel for the function.

Cleanup and cancellation need separate decisions

An awaited defer does not automatically shield its work from cancellation. If cleanup checks cancellation and exits early, moving it into defer does not change that behavior. SE-0493 cancellation discussion.

Here is a deliberately small stand-in for a cancellation-sensitive audit writer:

actor AuditTrail {
    private(set) var flushed = false

    func flush() throws {
        try Task.checkCancellation()
        flushed = true
    }
}

Swift 6.0 workaround:

func flushBeforeReturning(_ audit: AuditTrail) async throws {
    let cleanup = Task {
        try await audit.flush()
    }
    try await cleanup.value
}

This creates an unstructured task and waits for it. It is not a detached, fire-and-forget operation. The actor reference is safe to share, but other captured values would need their own concurrency review.

Swift 6.4:

@available(anyAppleOS 27, *)
func flushBeforeReturning(_ audit: AuditTrail) async throws {
    try await withTaskCancellationShield {
        try await audit.flush()
    }
}

The shield temporarily prevents the operation from observing the enclosing task’s cancellation. It does not undo cancellation, guarantee successful I/O, or repair an already-failed connection. Use it around cleanup that must finish, and keep that work bounded. SE-0504.

This distinction is worth a test of its own. “The cleanup function was called” and “the required cleanup completed” are different assertions.

Optional protocol types lose a pair of parentheses

These shared types give us a concrete example:

protocol StatusSink {
    func record(_ message: String)
}

struct ConsoleSink: StatusSink {
    func record(_ message: String) {
        print(message)
    }
}

Swift 6.0:

struct ReportJob {
    var sink: (any StatusSink)?
}

func makeSink(enabled: Bool) -> (some StatusSink)? {
    enabled ? ConsoleSink() : nil
}

Swift 6.4:

struct ReportJob {
    var sink: any StatusSink?
}

func makeSink(enabled: Bool) -> some StatusSink? {
    enabled ? ConsoleSink() : nil
}

The types mean the same thing in both versions. The stored existential can contain different conforming types; the opaque return still represents one underlying concrete type, optionally absent. Existing parenthesized code remains valid. SE-0521.

This is a readability improvement, not a new concurrency guarantee. And the name after any must describe an appropriate protocol constraint: any Int? is not valid because Int is a concrete type.

I would adopt this spelling when touching the surrounding code. It does not justify a broad rewrite of otherwise stable files by itself.

Borrow elements without inventing shared ownership

Sequence’s iterator returns an owned optional element. That model cannot provide general borrowing iteration over values that prohibit copying. Iterable adds that capability through familiar for loops. It does not mean every existing array loop performs an expensive deep copy; the optimizer and the element’s representation still matter. SE-0516.

Suppose each lease is deliberately noncopyable:

struct Lease: ~Copyable {
    let identifier: Int
}

func inspect(_ lease: borrowing Lease) -> Int {
    lease.identifier
}

Swift 6.0: one possible reference-wrapper approach

final class LeaseBox {
    let lease: Lease

    init(_ lease: consuming Lease) {
        self.lease = lease
    }
}

func leaseIdentifiers() -> [Int] {
    let leases = [
        LeaseBox(Lease(identifier: 11)),
        LeaseBox(Lease(identifier: 22))
    ]

    var identifiers: [Int] = []
    for box in leases {
        identifiers.append(inspect(box.lease))
    }
    return identifiers
}

The wrapper gives Array a copyable reference to store. It also introduces reference ownership that our lease model did not ask for. Custom containers or direct access were other options; a wrapper was never the only possible implementation.

Swift 6.4: noncopyable storage with borrowing iteration

@available(anyAppleOS 27, *)
func leaseIdentifiers() -> [Int] {
    var leases = UniqueArray<Lease>()
    leases.append(Lease(identifier: 11))
    leases.append(Lease(identifier: 22))

    var identifiers: [Int] = []
    for lease in leases {
        identifiers.append(inspect(lease))
    }
    return identifiers
}

Two features cooperate here. UniqueArray owns the noncopyable elements; its Iterable conformance enables the loop to borrow them. We copy the integer identifiers into the result, not the leases. SE-0527.

That is a change in what the type system can represent. Whether it improves a particular program’s performance still requires measurement. I would reach for this when ownership or measured allocation costs justify it, rather than replacing ordinary arrays everywhere.

Migrate test helpers without losing failures

Having XCTest and Swift Testing tests in the same project was already possible. The awkward part was calling an assertion helper from the opposite framework and assuming a failed assertion would be recorded correctly. Swift 6.4 adds targeted interoperability for that situation. ST-0021.

Swift 6.0: keep the helper and test in XCTest

import XCTest

func assertReportName(_ name: String,
                      file: StaticString = #filePath,
                      line: UInt = #line) {
    XCTAssertTrue(name.hasSuffix(".csv"), file: file, line: line)
}

final class ReportNameTests: XCTestCase {
    func testCSVName() {
        assertReportName("monthly.csv")
    }
}

Swift 6.4: move the test while retaining the helper

import XCTest
import Testing

func assertReportName(_ name: String,
                      file: StaticString = #filePath,
                      line: UInt = #line) {
    XCTAssertTrue(name.hasSuffix(".csv"), file: file, line: line)
}

@Test func csvName() {
    assertReportName("monthly.csv")
}

The reverse migration also works for supported assertions. Replace the assertion body in an XCTest method with #expect, after importing Testing:

import XCTest
import Testing

final class ReportNameTests: XCTestCase {
    func testCSVName() {
        #expect("monthly.csv".hasSuffix(".csv"))
    }
}

For Swift packages, the toolchain and package tools version determine the default interop mode:

ToolchainPackage tools versionDefault
Earlier than 6.4Anynone
6.4 or laterEarlier than 6.4limited
6.4 or later6.4 or latercomplete

In limited mode, failed XCTest assertions called from Swift Testing become warnings. Use complete when those failures must fail the test. You can select it explicitly:

SWIFT_TESTING_XCTEST_INTEROP_MODE=complete swift test

These details are documented in Apple’s migration guide. An Xcode scheme can set the same environment variable for its test action; verify the behavior in the runner your CI actually uses.

Migration still has boundaries. This does not make XCTest expectation and waiter APIs generally safe inside Swift Testing’s concurrency model. Their migration needs separate attention. ST-0021 scope.

My first check would be to deliberately feed the helper "monthly.txt". A migration that compiles and passes only good inputs has not yet demonstrated that it still detects a failure.

Less actor annotation is a configuration choice

If you are jumping from 6.0 directly to 6.4, you also pick up earlier work. Default main-actor isolation is one example: it arrived in Swift 6.2 through SE-0466.

Swift 6.0:

@MainActor
final class ReportScreenModel {
    private(set) var status = "Ready"

    func markExported() {
        status = "Exported"
    }
}

Swift 6.4, with the target’s default isolation set to MainActor:

final class ReportScreenModel {
    private(set) var status = "Ready"

    func markExported() {
        status = "Exported"
    }
}

For a Swift package, the target setting is .defaultIsolation(MainActor.self), available with package tools version 6.2 or newer. For a standalone compiler check, use -default-isolation MainActor.

Removing the annotation without that setting does not preserve this example’s isolation. Nor does upgrading the compiler automatically change every target’s default. A UI-oriented target may benefit; a shared server library may need a different choice.

Treat changes to isolation as changes to your API’s concurrency contract. They deserve more scrutiny than a formatting cleanup.

How I would introduce these changes

Start by building the existing project with the new compiler and its existing settings. Establish which diagnostics come from the upgrade before also changing isolation defaults or replacing test infrastructure.

Then choose a small slice of real code. An export path with repeated cleanup is a reasonable candidate. Test success, a thrown error, and cancellation. For testing interop, prove that an intentionally bad result fails in CI. For borrowing iteration, check the ownership requirement first and benchmark the relevant workload if performance is the motivation.

Check runtime availability separately. In the Apple SDK inspected for this article, UniqueArray, Iterable, and withTaskCancellationShield require the version 27 OS generation. The examples carry availability annotations for that reason. New syntax such as optional protocol spelling is a different kind of change from calling a new runtime API.

The useful question is whether these changes make the next modification easier to get right. Keeping cleanup in one place and expressing ownership in the type system both help. So does a test migration that can proceed incrementally while preserving meaningful failures.

Those are improvements I can put to work.

Sources and example verification

The starting point for this article was BB – iOS Developer’s discussion of Swift 6.4. The explanations and examples here were written independently and checked against the official release notes, language proposals, and testing documentation linked above.

The Swift examples were checked with Apple Swift 6.4 in Swift 6 language mode. The “Swift 6.0” examples use the earlier APIs and syntax, but were not run with a separate 6.0 compiler; -swift-version 6 selects a language mode and does not emulate that older toolchain.

On macOS 26.7, the availability, optional-type, isolation, and cleanup examples passed runtime checks, as did the older iteration and cancellation workarounds. The Swift 6.4 borrowing-collection and cancellation-shield examples were compile-checked only because their runtime APIs require OS 27. All three test-framework examples passed with valid input and failed with deliberately invalid input in complete mode. The migrated Swift Testing example also confirmed the warning-only behavior of limited mode.

Put the ideas to work

Building something that needs experienced technical judgment?

Zardoz Group helps teams turn complex cloud, AI, and software challenges into systems that deliver.

Talk with Zardoz Group