Swift 6.4 is not the kind of upgrade I would merge after seeing one green Xcode build. It changes the build engine used by Swift Package Manager, adds language syntax that generators may not understand, advances ownership substantially, and ships new cross-platform libraries. Each change is useful. Together, they widen the surface area that a production migration needs to test.
The good news is that Swift 6.4 does not force you to rewrite the application. Compiler version, swift-tools-version, and Swift language mode remain separate decisions. A team can install the compiler, build existing code in its current language mode, fix tooling, and adopt new APIs later.
This guide is an upgrade plan rather than a tour of every feature. It is based on the official Swift 6.4 release announcement from September 15, 2026 and the implemented Swift Evolution proposals linked throughout. Start on a branch, preserve a known-good toolchain, and make every change reversible.
1. Define the migration boundary before touching code
Write down what “migrated” means for your repository. For an iOS application with internal packages, I normally require all of the following:
- The app builds with the intended Xcode and Swift compiler in Debug and Release.
- Every Swift package builds and tests from the command line.
- Package plugins, macros, generated sources, resources, and binary targets work from a clean checkout.
- CI covers every supported host or destination, not only the developer’s Mac.
- The shipped application keeps the same deployment targets and behavior.
- A documented rollback can restore the previous toolchain and lockfile.
Commit or tag the last known-good state first. Record these outputs in the migration issue:
swift --version
swift package describe
swift package show-dependencies
git rev-parse HEAD
Do not raise // swift-tools-version: merely because you installed a new compiler. That line controls which manifest API SwiftPM may use and the minimum toolchain that can consume the package. Likewise, moving a target into Swift 6 language mode is a separate source-migration decision. If your package architecture already makes that distinction fuzzy, review the boundaries described in SPM modularization for large iOS codebases before changing every target at once.
2. Pin the toolchain locally and in CI
On macOS and Linux, Swiftly can install the exact open-source release and share the selection through .swift-version:
swiftly install 6.4.0
swiftly use 6.4.0
swift --version
When run inside a repository, swiftly use 6.4.0 can create a .swift-version file containing 6.4.0. Commit that file if Swiftly is part of the team’s supported workflow. A new CI worker can then run:
swiftly install
swift --version
swift build
swift test
The official Swiftly toolchain documentation explains the repository-local selection behavior. Pin the full patch version in reproducible jobs; latest is convenient for experimentation but can silently move CI to 6.4.1 later.
For an Apple-platform app, distinguish two environments:
- Use the Swift.org 6.4 toolchain to evaluate standalone packages and compiler behavior.
- Use the Swift compiler bundled with your selected Xcode for SDK integration, archiving, signing, and App Store submission.
Apple’s macOS toolchain guidance explicitly notes that App Store submissions must use the Swift version included in Xcode. Record xcodebuild -version, xcode-select -p, and xcrun swift --version in the Apple CI log. A green swift test with a downloaded toolchain is not evidence that an archive built with a different Xcode will succeed.
Keep the old environment available during rollout. With Swiftly, that can be another installed version. In Xcode CI, retain the previous runner image or Xcode application until the release branch has shipped.
3. Treat Swift Build as a build-system migration
Swift 6.4 makes Swift Build the default build engine in SwiftPM. The goal is consistent package builds across macOS, Linux, and Windows, but a new default can expose repository assumptions that the previous path happened to tolerate.
Run from a clean checkout and test more than compilation:
swift package reset
swift package resolve
swift build
swift test
swift build -c release
Pay special attention to build-tool plugins, command plugins, generated files, resource bundles, linker flags, binary artifacts, and scripts that inspect .build internals. .build is an implementation detail; a script that relies on a particular subdirectory layout is already fragile. Also test swift run for executable products because launching a product exercises a different path from compiling a library.
Do not paper over failures by immediately changing package manifests. First reproduce the failure with a minimal command and save the verbose log. Determine whether you found an invalid repository assumption, a plugin compatibility problem, or a Swift Build regression. The SwiftPM 6.4 documentation is the release-specific source of truth.
SwiftPM 6.4 can also generate software bills of materials in SPDX or CycloneDX formats. That is valuable for supply-chain inventory, but it is not required to finish the compiler migration. Introduce SBOM generation as a separate CI change so its failures cannot obscure build-engine failures.
4. Adopt Subprocess 1.0 deliberately
The swift-subprocess package reached 1.0 with Swift 6.4. It provides a concurrency-native, cross-platform replacement for the common pile of Process, pipes, readability handlers, and platform conditionals. It is a package dependency, not a magical new standard-library namespace.
Pin the stable major series in Package.swift:
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "BuildSupport",
platforms: [.macOS(.v14)],
products: [
.executable(name: "release-check", targets: ["ReleaseCheck"])
],
dependencies: [
.package(
url: "https://github.com/swiftlang/swift-subprocess.git",
from: "1.0.0"
)
],
targets: [
.executableTarget(
name: "ReleaseCheck",
dependencies: [
.product(name: "Subprocess", package: "swift-subprocess")
]
)
]
)
Here is a complete executable that streams standard output while capturing bounded standard error. The executable path and arguments are passed directly—there is no shell interpolation—which is exactly what you want when values originate outside the program.
import Foundation
import Subprocess
@main
struct ReleaseCheck {
static func main() async throws {
let result = try await run(
.name("swift"),
arguments: ["test", "--parallel"],
input: .none,
output: .sequence,
error: .string(limit: 64 * 1024)
) { execution in
var lineCount = 0
for try await line in execution.standardOutput.strings() {
lineCount += 1
print("[swift-test] \(line)")
}
return lineCount
}
print("Streamed \(result.closureResult) lines")
guard result.terminationStatus.isSuccess else {
if let diagnostic = result.standardError {
FileHandle.standardError.write(Data(diagnostic.utf8))
}
throw TestCommandFailed(status: result.terminationStatus)
}
}
}
struct TestCommandFailed: Error {
let status: TerminationStatus
}
This example requires the Foundation-backed package trait, enabled by default, for Data and FileHandle. The Execution and its stream values are valid only inside the closure; do not store them for later. The package’s official README and 1.0 API examples also document output limits and graceful cancellation. Always bound captured output, propagate task cancellation, and check the termination status rather than assuming “no thrown error” means the child succeeded.
5. Simplify availability with anyAppleOS
When an API has the same availability across Apple’s version-aligned operating systems, Swift 6.4 understands anyAppleOS:
@available(anyAppleOS 26.0, *)
func configureSharedVisualEffect() {
// API available from version 26 across Apple operating systems.
}
if #available(anyAppleOS 26.0, *) {
configureSharedVisualEffect()
}
#if os(anyAppleOS)
import Darwin
#endif
Platform-specific annotations can override the shared default:
@available(anyAppleOS 26.0, watchOS 26.4, *)
func configureExtendedEffect() {}
Two constraints matter. First, anyAppleOS represents Apple operating systems; it does not include Linux, Windows, Android, or Wasm. Second, its version cannot be lower than 26 because Apple’s aligned versioning begins there. Do not mechanically replace older annotations such as iOS 17, macOS 14—those versions do not map to one shared number.
6. Use async defer for cleanup, not hidden workflow
SE-0493 allows an asynchronous call inside a defer in an async context. Deferred async work is awaited before the function exits:
func importArchive(at url: URL, audit: AuditClient) async throws {
let session = try await ImportSession.open(url)
defer {
await audit.recordImportFinished(url)
await session.close()
}
try await session.validate()
try await session.persist()
}
This is excellent for cleanup tied to lexical scope. It is poor hiding place for an unrelated multi-step business workflow because readers naturally scan defer as cleanup. Cancellation still matters: suspension does not automatically make cleanup immune to cancellation. Swift 6.4 also adds withTaskCancellationShield; use it only when a cleanup operation genuinely must be isolated from the enclosing task’s cancellation, and design the called operation to terminate.
7. Resolve symbol conflicts with module selectors
Qualifying a type as Payments.Event used to be ambiguous: Payments itself might be a declaration rather than the module. SE-0491 adds the explicit :: module selector:
import Analytics
import Payments
let checkoutEvent = Analytics::Event(name: "checkout")
let receiptEvent = Payments::Event(identifier: "receipt")
Use selectors at the conflict, not throughout the entire codebase. They improve clarity when two dependencies export the same name, but excessive qualification is a signal that your own public naming or dependency boundary may need work. Source generators, formatters, syntax rewriters, and macros that parse type expressions must be tested with Module::Type before you adopt the spelling broadly.
8. Turn diagnostics into a staged migration tool
SE-0522 introduces @diagnose, which can promote, demote, or ignore a named warning group within a declaration’s lexical scope:
@diagnose(
DeprecatedDeclaration,
as: warning,
reason: "Compatibility bridge is removed after the server rollout"
)
func callLegacyEndpoint() {
legacyRequest()
}
The supported behaviors are error, warning, and ignored. Prefer a narrow annotation with a reason and removal issue over weakening warnings for the entire target. Conversely, promote a warning group to an error in new code when you want the compiler to enforce a policy before older modules are ready.
Warning groups are identifiers emitted with compiler diagnostics; do not invent one from the text of a warning. Capture the group from Swift 6.4’s diagnostic output, and remember that older compilers cannot parse new syntax. That is another reason to pin the minimum toolchain before committing @diagnose to shared source.
9. Update testing without rewriting the suite
Swift 6.4 deliberately makes mixed XCTest and Swift Testing suites easier to migrate. ST-0021 permits XCTAssert in Swift Testing tests and #expect in XCTest methods in supported contexts. This is a bridge, not a reason to mix styles forever. Move helpers and assertions incrementally, while keeping lifecycle and test discovery predictable.
The command-line runner adds per-test-case repetition controls:
swift test --maximum-repetitions 20 --repeat-until fail
Use this to reproduce a suspected flaky test, not to normalize flakiness in the main pipeline. Swift 6.4 repeats only the cases meeting the repetition condition rather than rerunning the whole target. It also supports attachments conforming to Transferable on Apple platforms and CustomTestReflectable for clearer failed-expectation output.
If the suite is still primarily XCTest, the migration patterns in Swift Testing for senior iOS engineers remain useful. Run the tests both through Xcode and swift test where applicable; their hosting environments, destination behavior, and available SDK frameworks are not identical.
10. Audit packages, macros, and generated code
Dependency compatibility deserves its own pass. Resolve from the committed Package.resolved, then test an intentional update separately. A compiler migration plus a dependency update produces a noisy failure set and a poor rollback.
For each package, check:
- Its declared tools version and documented Swift version range.
- Whether binary artifacts include the required platforms and architectures.
- Whether build plugins assume the old build directory layout.
- Whether macros build for the host while their client code builds for the destination.
- Whether generated Swift accepts new spellings such as
anyAppleOS,Module::Type,@diagnose, and ownership accessors. - Whether checked-in generated output is reproducible from a clean checkout.
Macros and generators are especially sensitive because they depend on SwiftSyntax or parse Swift source. Pin compatible releases rather than guessing that a package built for an earlier syntax tree will understand 6.4. Generate code in CI and fail if git diff --exit-code reports an unexpected change.
11. Run a cross-platform migration matrix
Swift Build’s cross-platform consistency is valuable only if you exercise it. A practical package matrix contains the previous production compiler and 6.4 on each operating system the package claims to support:
| Job | Purpose |
|---|---|
| macOS + previous Xcode | Proves rollback remains viable |
| macOS + selected Xcode | Builds, tests, archives, and signs the app |
| Swift 6.4 on macOS/Linux/Windows | Validates portable package products |
| Swift 6.4 + Android SDK, if supported | Validates matching SDK and host toolchain |
| Release configuration | Catches optimizer and linker-only failures |
Do not run Apple-framework targets on Linux and call the matrix broken. Split portable domain packages from platform adapters and test each where it is supported. The matching-toolchain rule is particularly important for cross-compilation SDKs. The Swift 6.4 Android SDK, for example, should be paired with the exact 6.4 host toolchain; the workflow in sharing Swift business logic with Android explains that boundary.
12. Know which ownership features shipped
Preview material is not a release manifest. In this case, however, the major ownership features highlighted before release did ship in Swift 6.4:
borrowandmutateaccessors — SE-0507RefandMutableRef— SE-0519UniqueBox— SE-0517UniqueArray(andRigidArray) — SE-0527Iterableborrowing iteration — SE-0516- improvements for optionals of noncopyable types — SE-0532
- noncopyable conformances to
Equatable,Comparable, andHashable
That corrects the status, not the engineering advice, in the earlier Swift 6.4 ownership performance preview. These APIs are released, but you should still profile before replacing familiar Array or copy-on-write designs. Their purpose is to express ownership and eliminate copies that matter—not to decorate ordinary model code.
Do not extend that conclusion to every ownership idea shown in a talk, forum vision, or main snapshot. An idea is shippable only when the 6.4 release notes or its accepted proposal says it is implemented in Swift 6.4. For example, proposals still under review after the release cutoff, and future directions inside implemented proposals, are not silently part of the language. Base production code on released toolchain behavior, not a development snapshot.
13. Roll out with a tested rollback
Make the upgrade boring. Land it in layers:
- Pin the new toolchain and add the CI matrix without adopting new syntax.
- Fix Swift Build, package, plugin, macro, and generated-code compatibility.
- Run the full app test plan and archive a Release build.
- Adopt small language conveniences in isolated commits.
- Introduce ownership APIs only with benchmarks and focused tests.
Your rollback should restore the previous Xcode or .swift-version, the previous Package.resolved, and any manifest changes as one known operation. If source has begun using 6.4-only syntax, reverting only the toolchain will not work. Keep feature adoption commits separate so they can be reverted cleanly.
Before merging, compare launch, binary size, compile duration, test duration, and any performance-sensitive benchmarks against the baseline. Compiler upgrades can improve these numbers, but “newer” is not itself a measurement.
Key Takeaways
- Upgrade the compiler, package tools version, and language mode as separate decisions.
- Pin Swift 6.4.0 locally and in CI; record the Xcode compiler used for Apple builds.
- Test Swift Build as a genuine build-engine change, including plugins and generated artifacts.
- Adopt Subprocess 1.0,
anyAppleOS, asyncdefer, module selectors, and@diagnosewhere they solve a concrete problem. - Use Swift Testing’s new interoperability and repetition controls to migrate and diagnose—not to conceal unstable tests.
- The highlighted ownership APIs are shipped in 6.4, but unrelated preview ideas are not automatically included.
- Keep the previous toolchain, dependency lockfile, and release path available until production evidence says the migration is safe.
Swift 6.4 is a broad, practical release. Its best feature may be consistency: one SwiftPM build engine across major hosts, stable process APIs, clearer source diagnostics, and ownership tools that have moved from previews into a release. Treat the migration as infrastructure work, and your product code can adopt those capabilities at its own pace.