2.2 — Projects, workspaces, targets, schemes

Opening scenario

You join a team. They send you a git clone URL. You open the repo and see:

MyApp.xcworkspace
MyApp.xcodeproj
MyApp/
MyAppTests/
MyAppUITests/
MyAppWidget/
Pods/
Packages/

Which file do you double-click? What’s the difference between the workspace and the project? Why are there a workspace AND a project? What’s that “scheme” dropdown in the toolbar? Why are there five schemes?

The answers feel obvious after a year of iOS work. They feel mysterious for the first three months. This chapter compresses the mystery into one read.

The four entities

Workspace  (.xcworkspace)        ← what you open
   ├── Project A  (.xcodeproj)
   │     ├── Target: MyApp
   │     │     ├── Build settings
   │     │     ├── Sources (files)
   │     │     ├── Resources (assets, plists)
   │     │     ├── Frameworks (linked libraries)
   │     │     └── Build phases
   │     ├── Target: MyAppTests
   │     ├── Target: MyAppWidget
   │     └── Scheme: MyApp        ← "how to build/run"
   │     └── Scheme: MyAppWidget
   └── Project B (e.g., dependency)
   └── Swift Packages (resolved)
EntityWhat it isWhen you create one
ProjectA bundle of targets sharing a folder of sourceOne per app
WorkspaceA container holding multiple projects + packagesWhen you have >1 project, or use CocoaPods, or use local SwiftPM dependencies
TargetOne product (app, framework, extension, test bundle)Each thing you can build separately
SchemeA recipe for building + running + testing + archivingOne per target you want runnable, plus variants (Debug, Staging, Release)

Rule of thumb: always open the .xcworkspace if one exists

If you double-click the .xcodeproj while a .xcworkspace also exists, you’ll get build errors (“Module ‘Pods_MyApp’ not found”). The workspace is the file that knows about your dependencies; the project alone doesn’t.

Concept → Why → How → Code

Project

A project lives in a folder named MyApp.xcodeproj. Despite the .xcodeproj extension, it’s a directory:

$ ls MyApp.xcodeproj
project.pbxproj          xcshareddata/
project.xcworkspace/     xcuserdata/

The project.pbxproj file is OpenStep-format ASCII (a 1990s NeXT format). It records:

  • Every source file in the project and which target owns it
  • Build settings (compiler flags, code-sign identities, etc.)
  • Build phases (compile sources, copy resources, link frameworks)
  • Targets and their dependencies

This is the file that causes 90% of merge conflicts on iOS teams. Two developers add a file at the same time → both append to the PBXFileReference section → merge conflict. Mitigations:

  • Tools like XcodeGen or Tuist that generate the project from a YAML/Swift spec checked into git.
  • Move source code into Swift packages (Packages are SPM files — Package.swift — which merge cleanly).
  • Use kebab-case filenames and follow a project structure convention so additions are predictable.

Workspace

A .xcworkspace is a tiny XML file listing the projects/packages inside it:

<Workspace version="1.0">
  <FileRef location="group:MyApp.xcodeproj"/>
  <FileRef location="group:Pods/Pods.xcodeproj"/>
  <FileRef location="group:Packages/DesignSystem"/>
</Workspace>

You need one when:

  1. CocoaPods generates Pods.xcodeproj and bundles your project into a workspace.
  2. Multiple Xcode projects depend on each other (e.g., framework project + app project).
  3. Local Swift packages are added via “Add Local Package” — adds the package as a peer to your project.

If you have only an app with only remote SPM dependencies, you don’t need a workspace — the .xcodeproj alone is enough.

Target

A target is a single buildable product. Common kinds:

Target typeProducesExample
iOS App.app bundleThe main MyApp
App Extension.appex bundleWidget, Share Extension, Notification Service
Framework / Static Library.framework / .aShared code
Unit Test Bundle.xctestMyAppTests
UI Test Bundle.xctest (UI runner variant)MyAppUITests
macOS App.app bundleA Mac Catalyst or pure AppKit version

A real-world app commonly has 5–10 targets: app, watchOS companion, widget extension, intent extension, notification service, unit tests, UI tests, snapshot tests. Each target has its own build settings and decides which files it compiles (“target membership” — the checkbox in the File Inspector).

Scheme

If a target is “what to build,” a scheme is “how, when, and with what flags.” A scheme bundles five actions:

  1. Build — which targets to build (your app target almost always)
  2. Run — what to launch with the debugger attached, with what environment variables, arguments, executable
  3. Test — which test targets to run, what test plans, parallelization
  4. Profile — what to launch under Instruments
  5. Analyze — static analysis target
  6. Archive — what to package for App Store / Ad Hoc distribution

You’ll typically have multiple schemes per target. The common pattern:

  • MyApp — Debug build, hits dev backend
  • MyApp (Staging) — Release build, hits staging backend, points TestFlight uploads at a beta App Store Connect record
  • MyApp (Prod) — Release build, hits prod backend, App Store distribution

The differences between these schemes are usually a combination of build configuration, environment variables, and a launch argument.

The Edit Scheme dialog (Product → Scheme → Edit Scheme, or ⌘<)

Things you’ll change here regularly:

  • Run → Arguments → Environment Variables: set OS_ACTIVITY_MODE=disable to silence noisy system logs, or MY_API_BASE=https://staging.example.com to switch backends without code changes.
  • Run → Diagnostics → Address Sanitizer / Thread Sanitizer / Main Thread Checker — turn these on for debug builds to catch entire classes of bugs at runtime.
  • Test → Test Plans — multiple plans for unit vs integration vs perf tests.
  • Run → Build Configuration — Debug for the Run action, Release for the Profile and Archive actions.

Shared vs user schemes

In the scheme list (⌃0) you see “Shared” and “User” sections. A shared scheme is checked into git (xcshareddata/xcschemes/); a user scheme is local to you (xcuserdata/<username>.xcuserdatad/). Almost always you want schemes shared — otherwise CI can’t build your app.

Open Product → Scheme → Manage Schemes and tick “Shared” for the schemes that should be in git. This single checkbox has cost teams entire afternoons.

In the wild

  • Apple’s WWDC sample projects ship as bare .xcodeproj with no workspace — they have zero CocoaPods and only SPM dependencies. Modern, minimal.
  • The Wikipedia iOS app uses a workspace with ~30 frameworks structured per feature. Build times approach 4 minutes from clean.
  • Tuist and XcodeGen are the two project-generation tools used at Spotify, Airbnb, Bumble, and SoundCloud to escape .pbxproj merge hell. They let you describe your project structure as code.
  • xcodebuild (the command-line equivalent) takes the same project/workspace/scheme arguments — xcodebuild -workspace MyApp.xcworkspace -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 16' build. CI scripts speak this dialect.

Common misconceptions

  1. “Targets and schemes are the same thing.” No. Targets describe what to build (sources + settings). Schemes describe how to drive it (which configuration, what env vars, which tests to include). One target can have many schemes.

  2. “You should use one target per environment (Prod / Staging / Dev).” Almost always wrong. Targets are heavy — each has its own settings, Info.plist, asset catalog. Use one target with multiple schemes / configurations to switch environment. Only create separate targets when the binary truly differs (e.g., enterprise vs App Store edition).

  3. “I don’t need a workspace for SPM-only projects.” Correct! This is a good simplification many teams haven’t adopted yet. If your dependencies are all remote SwiftPM packages, drop the workspace and open the .xcodeproj directly.

  4. “Adding a file via Finder is fine.” No — Xcode tracks files in the project file. Drag the file into Xcode, choose target membership in the dialog. Finder-added files are invisible to the build until you add them through Xcode’s UI.

  5. “Marking everything as Shared is the safe default.” Mostly — but personal experimental schemes (e.g., your one-off run config for chasing a bug) should stay user-local. Otherwise your scheme list balloons in PRs.

Seasoned engineer’s take

The mental model that unlocks Xcode: everything is a build setting. The GUI is a thin layer over hundreds of keyed settings stored in .pbxproj and resolvable from .xcconfig files. Once you internalize this, you stop “configuring Xcode” and start “writing build settings.”

For team scaling, the path is consistent across every iOS shop above 5 engineers:

  1. Start with a single project, single workspace, plain Xcode.
  2. Add a .xcconfig per build configuration (Debug / Release / Staging).
  3. As features grow, extract them into SwiftPM packages — a DesignSystem package, an Networking package, a Feature_Profile package. Each package builds independently, indexes independently, tests independently.
  4. When the .pbxproj is regenerated more often than it’s edited, adopt Tuist or XcodeGen and stop editing it by hand.

The teams that don’t do this end up with 60-second incremental builds, weekly merge conflicts on project.pbxproj, and engineers waiting on the indexer.

TIP: Always tick “Shared” for new schemes, then commit xcshareddata/. CI will thank you. Your future self chasing “why does CI not see this scheme” will thank you more.

WARNING: Never drag files into the Xcode project from Finder without selecting target membership. The file will silently fail to compile (because no target owns it) and you’ll spend 20 minutes wondering why your new view is “not in scope.”

Interview corner

Question: “What’s the difference between a target and a scheme in Xcode?”

Junior answer: “A target is what you’re building, a scheme is how you build it.” → True but vague.

Mid-level answer: “A target is a buildable product: an app, an extension, a test bundle. It has its own sources, build settings, and Info.plist. A scheme is a recipe that selects targets and configurations for the five actions — Run, Build, Test, Profile, Archive — and can include environment variables, launch arguments, and per-action build configurations. The typical setup is one target per product and multiple schemes per target for different environments (Dev, Staging, Prod).” → Strong.

Senior answer: Plus: “I’d also call out the anti-pattern of using separate targets per environment — that’s an old Objective-C habit. You end up duplicating settings, plists, asset catalogs. The right pattern is one target, multiple build configurations (Debug, Release-Staging, Release-Prod) controlled by .xcconfig files, with schemes selecting which configuration to run. The behavior differences become environment variables and a Configuration.plist swap, not source-level branching with #if everywhere. And as projects grow, modularizing into SwiftPM packages is the real escape hatch — each package becomes its own indexable, testable, parallelizable build unit.” → Senior signal: knows the anti-pattern and the scale path.

Red-flag answer: “I use separate targets for Debug and Release.” → Tells the interviewer the candidate has been copy-pasting Info.plists for two years.

Lab preview

Lab 2.1 (Multi-target project) walks you through adding a Widget Extension target, a macOS target, and a Staging scheme to a starter app — the entities described above, made concrete.


Next: where targets and schemes actually do their thinking — the build settings layer. → Build settings & configurations