How do launch arguments and environment variables reach an app, and why does “-key value” override UserDefaults?

6.11.1 · L3 · Interview

Predict first

What does the app read when launched with -key value arguments, and what does set(false) do?

// === args.swift ===
import Foundation
let d = UserDefaults.standard
d.removeObject(forKey: "demoFlag")
print("arguments:", CommandLine.arguments.dropFirst().joined(separator: " "))
print("bool:", d.bool(forKey: "demoFlag"), "int:", d.integer(forKey: "demoCount"), "string:", d.string(forKey: "demoName") ?? "nil")
d.set(false, forKey: "demoFlag")
print("after set(false), bool is still:", d.bool(forKey: "demoFlag"))
print("environment:", ProcessInfo.processInfo.environment["DEMO_ENV"] ?? "nil")
print("argument domain keys:", d.volatileDomain(forName: UserDefaults.argumentDomain).keys.sorted())
// === run.sh ===
swiftc -swift-version 5 args.swift -o args 2>&1 | grep error
DEMO_ENV=x ./args -demoFlag YES -demoCount 7 -demoName hello
  • Values come from the arguments, set(false, forKey:) does not change what is read, and the argument domain lists the four keys
  • The arguments are ignored and the defaults are all empty
  • set(false) overrides the argument value

Mental model. Launch arguments are sticky notes on the door when the app arrives: “skip onboarding today”. The settings book (UserDefaults) is still on the desk, but the app reads the sticky note first.

A process receives arguments (CommandLine.arguments) and an environment (ProcessInfo.processInfo.environment) from whoever starts it. Foundation adds a twist: arguments of the form -key value are parsed into the argument domain of UserDefaults, the first domain searched. In the check, -demoFlag YES -demoCount 7 -demoName hello made bool(forKey:) return true, integer(forKey:) 7 and string(forKey:) “hello”; a following set(false, forKey:) did not change what the app reads, because the argument domain shadows the persistent one. It is volatile: a run without the arguments saw none of it. Arrays use the plist-like text form -demoList '(a,b)'.

This is the basis of several tools. Xcode’s scheme “Arguments Passed On Launch” and “Environment Variables” fields feed them; xcrun simctl launch <device> <bundle-id> -key value passes arguments to a simulator app (the passing itself was not run here, the parsing was); UI tests set XCUIApplication().launchArguments and launchEnvironment to start the app in a known state (a fake backend, onboarding skipped, animations off); and Apple’s debugging switches are exactly this (-AppleLanguages (de), -NSDoubleLocalizedStrings YES, -com.apple.CoreData.SQLDebug 1). For production code: treat them as test hooks, not features. Read your own flags once at startup into a configuration object, compile the code that honours them out of release builds (or ignore them unless a debug build is running), never let an argument weaken a security check, and remember a user cannot pass launch arguments to an installed iOS app on a device, so they are a development and automation channel only.

The volatile argument domain is searched before the persistent domain.

Common traps

  • Debugging “my default will not change” without checking the launch scheme. A leftover -key argument shadows every write.
  • Shipping a test-only flag that skips authentication. Gate such flags behind debug builds.

They might follow up with

  • How do UI tests start in a mocked state? Through launchArguments/launchEnvironment read in the app’s startup code.
  • Are environment variables persistent? No; they exist for that run only.

Related questions

  • 6.7.1 What is Info.plist for, and which keys matter most?
  • 6.4.3 Your app launches slowly. How do you find out why and what do you cut?
  • 6.11.2 In a scene-based app, which callbacks fire at launch and on activation, and which app delegate callbacks go…

In a scene-based app, which callbacks fire at launch and on… →