Why do some bugs appear only in Release builds, how do assert, precondition and fatalError differ, and how do you debug an optimised build?

23.3.2 · L5 · Interview

Predict first

The same code with a failing assert then a failing precondition runs at three optimisation levels. What are the exit codes and the number of lines printed?

// === main.swift ===
import Foundation

func say(_ s: String) { FileHandle.standardError.write((s + "\n").data(using: .utf8)!) }
func check(_ x: Int) {
    assert(x > 0, "assert")
    say("after assert")
    precondition(x > 0, "precondition")
    say("after precondition")
}
check(-1)
  • -Onone traps at the assert; -O skips the assert and traps at the precondition; -Ounchecked skips both
  • All three trap at the assert
  • All three run to the end

Mental model. A Debug build is a car with training wheels and warning lights; Release takes them off for speed. A bug that “only happens when the training wheels are off” was already there, just hidden.

A Release build changes your program in ways a Debug build does not. The optimiser reorders, inlines and removes code, so a race or an uninitialised value that happened to behave in Debug shows up; timing is faster, which changes which thread wins; checks are removed or kept depending on the check; #if DEBUG code is absent; and library behaviour can differ (a different linking or whole-module optimisation). The three trapping functions behave differently by build mode, and the check below runs the same code at three optimisation levels: assert is checked only in -Onone (Debug), precondition is also checked in -O (Release) but removed in -Ounchecked, and fatalError always stops. So use assert for developer-only invariants, precondition for checks that must hold in production, and fatalError for paths that must never run; and never put required side effects inside an assert, or they disappear in Release.

To debug a Release-only bug, reproduce it on the optimised build with debug information (Release has dSYMs by default), because the Debug build will not show it. Use logging and breakpoints with log actions rather than stepping, because optimised frames show <optimized out> and lines jump around; sanitizers (TSan for races, ASan for memory) work on optimised builds and are often the fastest route to the cause; try lowering optimisation (-Onone for one module or file via build settings) to see whether the bug disappears, which points at undefined behaviour or a race; and check build-setting differences between configurations (Q369). Fix the underlying issue (a race, an uninitialised value, an invalid assumption) instead of leaving Debug and Release different, and keep the CI testing Release too.

assert is a Debug-only check, precondition is kept in optimised builds, and -Ounchecked removes both.

Common traps

  • Putting required work inside assert(…). It vanishes in Release, so the app behaves differently.
  • Fixing a Release-only crash by turning optimisation off. The race or undefined behaviour is still in the code and will come back.

They might follow up with

  • Which check survives in Release? precondition and fatalError stay in -O; assert does not.
  • How do you debug without stepping? Logging breakpoints, os_log, sanitizers and a reproducible test.

Related questions

  • 23.3.1 What do the Address, Thread and Undefined Behavior sanitizers catch, what do they cost, and when do you use…
  • 23.1.2 How do expressions and printing work in LLDB (p, po, v), and how do you customise it with summaries, aliases…
  • 16.4.1 What are build configurations and build settings, how do xcconfig files layer, and how does #if DEBUG work?
  • 5.2.2 What does whole-module optimisation change, and what does it cost?

← What do the Address, Thread and Undefined Behavior sanitizers catch…