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)
| Entity | What it is | When you create one |
|---|---|---|
| Project | A bundle of targets sharing a folder of source | One per app |
| Workspace | A container holding multiple projects + packages | When you have >1 project, or use CocoaPods, or use local SwiftPM dependencies |
| Target | One product (app, framework, extension, test bundle) | Each thing you can build separately |
| Scheme | A recipe for building + running + testing + archiving | One 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
XcodeGenorTuistthat 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-casefilenames 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:
- CocoaPods generates
Pods.xcodeprojand bundles your project into a workspace. - Multiple Xcode projects depend on each other (e.g., framework project + app project).
- 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 type | Produces | Example |
|---|---|---|
| iOS App | .app bundle | The main MyApp |
| App Extension | .appex bundle | Widget, Share Extension, Notification Service |
| Framework / Static Library | .framework / .a | Shared code |
| Unit Test Bundle | .xctest | MyAppTests |
| UI Test Bundle | .xctest (UI runner variant) | MyAppUITests |
| macOS App | .app bundle | A 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:
- Build — which targets to build (your app target almost always)
- Run — what to launch with the debugger attached, with what environment variables, arguments, executable
- Test — which test targets to run, what test plans, parallelization
- Profile — what to launch under Instruments
- Analyze — static analysis target
- 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=disableto silence noisy system logs, orMY_API_BASE=https://staging.example.comto 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
.xcodeprojwith 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
.pbxprojmerge 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
-
“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.
-
“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).
-
“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
.xcodeprojdirectly. -
“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.
-
“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:
- Start with a single project, single workspace, plain Xcode.
- Add a
.xcconfigper build configuration (Debug / Release / Staging). - As features grow, extract them into SwiftPM packages — a
DesignSystempackage, anNetworkingpackage, aFeature_Profilepackage. Each package builds independently, indexes independently, tests independently. - When the
.pbxprojis regenerated more often than it’s edited, adoptTuistorXcodeGenand 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