Learn · Simulators

iOS simulators have outgrown simctl.

A practical look at why iOS simulator management feels harder every year, what changes when you stop targeting UDIDs, and the criteria that matter when you're evaluating a replacement.

Apple shipped simctl in 2014, alongside the iOS Simulator's first real CLI. The tool was designed for one developer on one Mac, building one app, on one simulator, in one place. That's still a perfectly reasonable workflow. It's also no longer what most iOS teams do.

If you've ever:

  • Watched a CI job fail because a hardcoded UDID didn't exist on the runner image.
  • Had to update three scripts the day Xcode 16 rotated a runtime identifier.
  • Sat through a 5-second xcrun call to figure out which simulator was booted.
  • Tried to teach an AI agent to find a specific simulator without giving it your shell history.
  • Spent half a day reconciling someone else's setup-simulator.sh with your machine.

…you've hit the friction this page is about. It compounds.

The problem with treating simulators as UDIDs

A UDID is a hash. It's unique per simulator per machine. The same "iPhone 16" running on your laptop and on your CI runner has two different UDIDs. The same "iPhone 16" before and after you reinstall Xcode has two different UDIDs. The same "iPhone 16" simulator on the same machine, after a simctl erase, keeps its UDID, unless someone deleted and recreated it instead, in which case it doesn't.

For scripts that move between machines, hardcoding UDIDs is unworkable. So every team eventually writes a wrapper. The wrapper inevitably:

  1. Calls xcrun simctl list -j to get the device list.
  2. Walks the deeply nested JSON to find the runtime you want.
  3. Filters by name within that runtime.
  4. Plucks the UDID.
  5. Hopes only one matched.

Five lines of plumbing for what should be one command. Worse, the wrapper breaks the next time Apple changes the JSON shape (it has, more than once), and it silently picks the wrong simulator the day someone adds a second iPhone 16 Pro on a different runtime.

The wrapper is also written in every team's slightly different way. The author leaves; nobody else trusts it; it accumulates "just in case" branches; the day it finally breaks is the day before a release.

This isn't a tooling problem. It's a category problem. simctl was built for a developer; teams that ship at scale are using it as infrastructure. The tool isn't wrong. The job description has changed.

What changes when names are first-class

The shift is small, in code:

flowdeck simulator boot "iPhone 16"

The shift is large, in practice. The script no longer cares about UDIDs. It no longer breaks across machines. It no longer breaks when Apple rotates runtime identifiers. The tool resolves the name at call time using rules you can predict:

  1. If the argument looks like a UDID, use it as-is.
  2. Otherwise, list only the simulators marked available.
  3. Try a case-insensitive exact match on name. If exactly one matches, use it. If multiple match, prefer one that's already booted; otherwise, the first.
  4. If no exact match, fall back to a case-insensitive substring match. If exactly one simulator's name contains the argument, use it. If multiple match, the tool refuses to guess and asks you to disambiguate.

You don't have to read the rules to use the tool. But if you read them once, your scripts get more deliberate. Predictable is the operative word. The tool will boot the wrong simulator if you ask it to (UDID match wins, even if that UDID is on a runtime you didn't expect), but it won't pick one when the name is ambiguous, that path is an explicit error, not a silent coin flip.

What this unlocks

For solo developers, name-based targeting is a minor convenience. For everyone else, it's structural:

CI matrices stop being a maintenance project.
A YAML job that runs flowdeck simulator boot "iPhone 16" works on every runner that has an iPhone 16 simulator installed. The job doesn't know or care about that machine's UDIDs.
Onboarding stops requiring a "fix my simulator" Slack thread.
The new engineer's local setup runs the same script as CI. If the script works in CI, it works on their laptop. Three days of "why doesn't this work for me" disappear.
AI agents get a sane targeting surface.
You can tell Claude Code or Codex to "boot iPhone 16" the way you'd tell a colleague. The agent doesn't need to learn shell-history-flavored UDID extraction.
Teams sharing scripts stop coordinating on UDIDs.
Open source iOS tooling that targets simulators can be portable without elaborate setup steps.

If you've ever wished simctl let you pass --by-name, this is the version of that idea where the wrapper is doing it on every operation, not just boot.

Evaluating a simulator-management tool

If you're considering swapping in a wrapper, FlowDeck or another, the criteria that matter aren't features. They're behaviors. Here's a short list to argue from, whether you're making the case internally or evaluating for yourself.

Predictability when things are ambiguous.
What happens when two simulators share a name? Pick a tool that documents its tie-breaking rules. "Just works" is not a behavior; it's an absence of one.
Composition with simctl.
A wrapper that replaces simctl entirely is a liability. The right wrapper composes: it accepts and returns the same UDIDs simctl uses, so you can drop down to simctl for the long tail (privacy grants, push, status bars).
JSON output on every operation.
simctl supports --json only on list. A useful wrapper supports it on every operation, with a flat schema jq can work with on the first try.
Runtime install by version.
Installing iOS 18.0 should not require finding a .dmg URL. The tool should know how to download from Apple's catalog and tell you the install progress.
Bulk cleanup operations.
Simulators accumulate. Operations like "delete the simulators whose runtime is no longer installed" and "delete simulators that were created but never booted" should be one command each, not a shell loop with three filter steps.
Non-interactive everything.
Every command should be usable from a CI script without a terminal attached. No interactive prompts, no Xcode dialogs, no "press enter to continue."

When simctl is still the right call

FlowDeck doesn't replace simctl, and a tool that tried to would be making the wrong bet. Some operations are surface-level wrappers around Apple capabilities that are not worth abstracting. Use simctl directly for:

  • Privacy permission grants. simctl privacy grants and revokes camera, location, contacts, and other privacy settings. Required for tests that need those permissions pre-granted.
  • Push notification simulation. simctl push <udid> <bundle-id> payload.apns for testing remote-notification handling.
  • Status bar overrides. simctl status_bar for marketing screenshots with a hand-set time and signal. Visual polish, not testing.
  • Keychain manipulation. simctl keychain for resetting or pre-seeding keychain state across runs.
  • Apple Watch pairing. simctl pair for watchOS testing topologies.

FlowDeck composes with simctl. The UDIDs FlowDeck returns are the UDIDs simctl expects. Nothing prevents you from using both in the same script. The design assumption is that if Apple's tool already does the job cleanly, the right thing to do is leave it alone.

Questions teams actually ask

We already have a simctl wrapper that mostly works. Why switch?

If your wrapper handles UDID resolution, runtime installation, and bulk cleanup, and it produces stable JSON output your scripts depend on, you might not need to. The honest case for switching is when the wrapper is becoming someone's part-time job to maintain, or when it breaks every Xcode major release. That's usually the moment teams notice the cost compounding.

Is this another abstraction layer we'll have to learn?

The surface is small enough to learn in an afternoon: list, boot, shutdown, erase, create, clone, delete, prune, plus a runtime subcommand. It maps 1:1 to simctl subcommands you already know. The thing you're learning isn't a new tool, it's a different default for how names and UDIDs interact.

What happens on a CI image that has multiple "iPhone 16" simulators?

FlowDeck applies the resolution rules: newest runtime first, then booted-vs-shutdown, then stable ordering. If you want to be explicit, pass the UDID directly, or pre-filter with flowdeck simulator list --platform iOS --json | jq ... to pick the one you want. The CI image should ideally not have ambiguous simulators, but the tool degrades gracefully when it does.

How does this play with Xcode Cloud or Fastlane?

Xcode Cloud manages simulators internally; you don't drive them directly. Fastlane's simctl integration continues to work; FlowDeck composes alongside, not against. Teams typically reach for FlowDeck where they currently shell out to raw simctl (in fastlane/Pluginfile wrappers, in custom sh steps, or in homemade CI orchestration).

Does this work for tvOS, watchOS, and visionOS simulators?

Yes. Pass --platform tvOS, --platform watchOS, or --platform visionOS to filter list operations. boot, shutdown, erase, create, and delete work across all four platforms; FlowDeck routes to the right simctl-equivalent under the hood.

What does name-based targeting actually pick when there's no perfect match?

Nothing. The match is full-string and case-insensitive, but it doesn't fuzz, prefix, or substring. "iPhone 16" will not match an "iPhone 16 Pro" simulator. If a script depended on prefix matching, that's a behavior to make explicit before switching.

How is this priced, and is it worth it for a small team?

$59 per developer per year, 14-day free trial, no credit card. The math is usually whether your team has spent more than 90 minutes a year on simulator setup, UDID reconciliation, or CI flakiness from simulator scripts. Most iOS teams answer yes after thinking about it.

Going deeper