How do per-target Swift language modes let a package migrate to Swift 6 one module at a time?

16.8.3 · L5 · Interview

Predict first

What happens when a Swift 6 target reads a mutable global from a Swift 5 target, and when it is also moved to Swift 5 mode?

// === run.sh ===
W=$(mktemp -d); cd $W; mkdir -p Sources/Legacy Sources/Modern Sources/App
manifest() { cat > Package.swift <<E
// swift-tools-version: 6.0
import PackageDescription
let package = Package(name: "App", targets: [
    .target(name: "Legacy", swiftSettings: [.swiftLanguageMode(.v5)]),
    .target(name: "Modern", dependencies: ["Legacy"]$1),
    .executableTarget(name: "App", dependencies: ["Modern"]),
])
E
}
echo 'public var counter = 0
public func bump() { counter += 1 }' > Sources/Legacy/L.swift
echo 'import Legacy
public func run() { bump(); print("counter", counter) }' > Sources/Modern/M.swift
echo 'import Modern
run()' > Sources/App/main.swift
manifest ""
echo "Modern in Swift 6 mode: $(swift build 2>&1 | sed 's/\x1b\[[0-9;]*m//g' | grep -E 'error: reference' | head -1 | sed 's#.*error: ##')"
manifest ', swiftSettings: [.swiftLanguageMode(.v5)]'
echo "Modern also in Swift 5 mode: $(swift build 2>&1 | grep -E 'Build complete|error')"
echo "$(swift run App 2>&1 | tail -1)"
  • Swift 6 mode: "reference to var counter is not concurrency-safe"; with .v5: Build complete and prints counter 1
  • It always builds because the global is declared in another module
  • Both fail in every mode

Mental model. You renovate a building one floor at a time: each floor gets the new fire code when it is finished, but the corridor it shares with an old floor still has to be walked carefully.

The tools version fixes the default language mode for the package (tools 6.0 means Swift 6 mode, which turns data-race safety into errors), but each target can override it with swiftSettings: [.swiftLanguageMode(.v5)] (and the package can list swiftLanguageModes as the default; the older name was swiftLanguageVersions). That makes migration incremental, module by module, leaves first: in the check, a Legacy target stays in Swift 5 mode and exports public var counter, which compiles there; a Modern target in Swift 6 mode that merely reads it fails with “reference to var 'counter' is not concurrency-safe because it involves shared mutable state”. The Swift 6 module is responsible for what it touches, even when the declaration lives in an unmigrated module. Switching Modern to .v5 too makes the package build and print counter 1, which shows the escape hatch is real and also that it only silences the diagnostics, not the race.

A workable plan: turn on strict concurrency checking in warning mode per target first (.enableUpcomingFeature and the strict-concurrency feature flags), fix warnings, flip the target to Swift 6 mode, and repeat on the next module; modules at the bottom of the graph go first because their public API shapes everyone above them (Q368). Annotate the boundary honestly: Sendable, actor isolation or nonisolated(unsafe) with a comment, not a blanket @preconcurrency. An Xcode app target has the same switch (SWIFT_VERSION and the strict concurrency build setting, Q369). This question is about the package mechanism; the language rules themselves are in the concurrency topics. What was not run here: the interaction with upcoming-feature flags in each combination.

The language mode of the using module decides which diagnostics it gets.

Common traps

  • Flipping the whole package to Swift 6 in one commit. The diff is unreviewable; use per-target modes.
  • Treating .v5 as “fixed”. It hides diagnostics; the data race is still there.

They might follow up with

  • Which modules go first? Leaves of the graph, because consumers see their isolation and Sendable choices.
  • Does a Swift 5 module’s public API stay usable from Swift 6 code? Yes, but the Swift 6 caller may have to handle non-Sendable or global state it cannot prove safe.

Related questions

  • 16.8.1 How do swiftSettings and their .when conditions work in a package, and which targets do they reach?
  • 16.7.4 Why does SwiftPM refuse a dependency that uses unsafeFlags, and what should you do instead?
  • 16.3.2 Why must the module dependency graph be acyclic, how do you layer it, and what does its shape do to build…
  • 16.4.1 What are build configurations and build settings, how do xcconfig files layer, and how does #if DEBUG work?

← What does the platforms: array in Package.swift mean, and why can a…   What are package traits, and how do they differ from build… →