What are build configurations and build settings, how do xcconfig files layer, and how does #if DEBUG work?
Predict first
The same program is run in the debug and the release configuration. What does each print?
// === Package.swift ===
// swift-tools-version: 6.0
import PackageDescription
let package = Package(name: "Cfg", targets: [.executableTarget(name: "T")])
// === Sources/T/main.swift ===
#if DEBUG
print("debug")
#else
print("release")
#endif
- debug, then release
- release, then release
- debug, then debug
Mental model. Build settings are a layered recipe card: the house recipe (platform), the family’s version (project), and tonight’s note on the fridge (target); and a handwritten note from the cook (command line) beats them all. xcconfig is writing the cards in a notebook instead of on loose scraps.
A build configuration is a named set of build settings; Xcode projects start with Debug and Release, and you can add more (Staging, Beta, Enterprise). A build setting is a key and value the build system uses: the optimisation level (SWIFT_OPTIMIZATION_LEVEL: -Onone in Debug, -O or -Osize in Release), the compilation mode (incremental per file in Debug, whole-module in Release), active compilation conditions (SWIFT_ACTIVE_COMPILATION_CONDITIONS, where DEBUG lives), the bundle identifier, deployment target, signing identity, header search paths, and hundreds more. Settings exist at several levels, and a lower level overrides a higher one: the platform defaults, then the project settings, then the target settings, and a level can reference the one above with $(inherited) so it adds instead of replacing. xcconfig files (plain-text KEY = value files, with #include for composition) are the maintainable way to hold them: they diff cleanly in review, avoid the opaque project-file edits, and can be shared across targets and repositories (Base.xcconfig included by Debug.xcconfig and Release.xcconfig, one per environment for API hosts and bundle suffixes). Command-line settings (xcodebuild FOO=bar) override even those, which is how CI injects a build number. Use xcodebuild -showBuildSettings to see the resolved value and where it came from.
Conditional compilation uses the configuration: #if DEBUG … #else … #endif includes code only when the DEBUG condition is set, which SwiftPM defines for the debug configuration, so the same file prints “debug” under swift run and “release” under swift run -c release. Keep #if for differences in what ships (debug menus, verbose logging, test-only hooks), not for core logic: code in the branch you do not build is not type-checked, so it rots, and Debug and Release behaving differently is a source of “only crashes in Release” bugs. Prefer a runtime flag or injected dependency to a compile condition when the behaviour must be testable in both modes. Practical rules: put secrets and per-environment values in xcconfig files that are not committed (or injected by CI), never rely on Debug-only behaviour for correctness, test the Release configuration in CI (optimisation changes timing and exposes undefined behaviour), mind that assert and assertionFailure are removed in optimised builds while precondition is kept in -O (and removed in -Ounchecked), and use separate bundle ids and icons for Debug, Beta and Release so they can coexist on one device.
SwiftPM defines DEBUG for the debug configuration only, so the compiler includes a different branch in each build.
Common traps
- Overwriting a setting without
$(inherited). You silently drop values from the level above, such as other compilation conditions. - Code that only works in Debug (or only compiles in Release). The unbuilt branch is never type-checked; test both configurations in CI.
They might follow up with
- How do you see why a setting has its value?
xcodebuild -showBuildSettingsshows the resolved value and its origin. - What does
#if DEBUGcompile in a release build? The#elsebranch only; the debug branch is not even type-checked.
Related questions
- 16.4.2 How do you speed up and diagnose slow builds: incremental builds, whole-module optimisation, explicit modules…
- 16.5.2 How do you use feature flags: compile-time versus runtime, ownership, and modularisation by team?
How do you speed up and diagnose slow builds: incremental builds… →