If you've built iOS apps on a team, you know this moment. You pull main, Git says CONFLICT (content): Merge conflict in project.pbxproj, and you open a wall of 24-character hex IDs.
Xcode 27.2 finally changes that. Here's what's new, explained without jargon.
TL;DR
- Xcode 27.2 replaces the old
project.pbxprojproperty list with a new JSON-basedproject.xcprojfile. - New projects use the new format by default. Existing projects are not converted automatically, and both formats work side by side.
- Xcode 27.0 and 27.2 can both open a JSON project. Xcode 26 cannot.
- Diffs get smaller, merge conflicts become rare, and you can read the file without special knowledge.
- Check your tools (CocoaPods, fastlane, scripts) before you switch.
First, what is an .xcodeproj?
MyApp.xcodeproj looks like a file, but it is a folder. Right-click it and choose Show Package Contents to see what's inside:
MyApp.xcodeproj/
├── project.pbxproj <- the old project description
├── project.xcworkspace/
├── xcshareddata/ <- shared schemes, Package.resolved
└── xcuserdata/ <- your personal UI state
Only one file changes in 27.2. project.xcproj replaces project.pbxproj. Nothing else in the bundle changes, so schemes, Package.resolved, and xcuserdata stay the same.
Why the old format hurt
project.pbxproj uses an old NeXTSTEP-era plist syntax. It stores your project as one large flat list of objects, and every object points to the others by random hex IDs.
To add a single Swift file, Xcode has to write it in four places:
- A
PBXFileReferenceso the file exists - A
PBXBuildFileso it gets compiled - An entry in a
PBXGroupso it shows in the navigator - An entry in the
PBXSourcesBuildPhaseso it reaches the target
Xcode generates both IDs fresh, so a teammate who adds a different file on their branch writes different IDs into the same four places. That's where the classic merge conflict comes from.
Build settings had the same problem. Each configuration (Debug, Release) kept its own full copy of the settings, so changing the deployment target meant editing two places.
What the new file looks like
Here's a small example app in the new format:
{
"default-configuration": "Debug",
"configurations": ["Debug", "Release",],
"files": [
{
"kind": "group",
"path": "Sources",
"children": [
{ "path": "WeatherApp.swift", "target-membership": ["Weather/compile-sources"] },
{ "path": "ForecastView.swift", "target-membership": ["Weather/compile-sources"] },
],
},
],
"targets": [
{
"name": "Weather",
"id": "...",
"product-type": "application",
"build-phases": ["compile-sources",],
"build-settings": {
"PRODUCT_BUNDLE_IDENTIFIER": "com.example.weather",
"SWIFT_VERSION": "6.0",
},
},
],
"build-settings": {
"IPHONEOS_DEPLOYMENT_TARGET": "27.0",
"SWIFT_OPTIMIZATION_LEVEL[config=Debug]": "-Onone",
"SWIFT_OPTIMIZATION_LEVEL[config=Release]": "-O",
},
}
You can probably read it without any explanation. Let's go through the three main ideas.
1. It's a tree, not a flat list
The file follows the structure you see in Xcode's UI. Instead of one big objects table, it is split into sections like files, targets, and build-settings, so a diff looks like what you actually did in the interface.
2. Files say which target they belong to
This is the most important change. In the old format, the target kept a list of its files. In the new format, each file lists its targets:
{ "path": "ForecastView.swift", "target-membership": ["Weather/compile-sources"] }
Read Weather/compile-sources as "compile this file in the Weather target." It combines the target name with the build phase, and no UUID is involved. Adding a file is now one line in one place.
If you need more detail, the entry becomes an object. For example, marking a header as public is done right there on the file, instead of inside PBXBuildFile.settings.ATTRIBUTES.
3. Build settings are written once
A setting that's the same in every configuration now appears once. When Debug and Release differ, the key carries a condition:
"SWIFT_OPTIMIZATION_LEVEL[config=Debug]": "-Onone",
"SWIFT_OPTIMIZATION_LEVEL[config=Release]": "-O",
If you've used .xcconfig files, this [config=...] syntax will look familiar. In one real migration, 161 lines of XCBuildConfiguration shrank to a single build-settings block, and the whole file went from 300 lines of property list to 123 lines of JSON.
Bonus: IDs are mostly gone
IDs are only assigned where they're needed, meaning targets and build products. Everything else is referenced by name or path.
Gotcha: it's "JSON", but with trailing commas
You might have noticed the trailing commas in the examples above. That's intentional. The format allows trailing commas, so a plain JSON.parse can't read it as-is. Apple's open-source library uses JSON5 encoding for this. If you're writing a script, use a JSON5-tolerant parser or Apple's library (see below) instead of jq or JSON.parse.
How to switch an existing project
In Xcode
- Select the project at the top of the Project navigator.
- Open the File inspector (
Option-Command-1). - Under Project Document, set Project Format to JSON.
- Confirm the dialog.
project.pbxproj is deleted and project.xcproj takes its place. Commit the change as its own commit, with no other edits, so it's easy to review and revert.
From the command line
This is handy for CI or for converting many projects at once:
xcodebuild -project MyApp.xcodeproj -convert-project "Xcode Project"
This command converts the project, and it does the same thing as choosing JSON in the File inspector.
New tooling: xcprojformatter and an open-source library
Apple published the format openly. xcode-project-format is a Swift library for reading, writing, and manipulating the project.xcproj format, so tools like generators, linters, and validators don't each have to reverse-engineer it.
Reading a project takes a few lines:
import XcodeProjectFormat
let data = try Data(contentsOf: projectURL)
let project = try XCSchema.Project(jsonRepresentation: data)
for target in project.targets {
print(target.name)
}
Xcode 27.2 also includes xcprojformatter in /usr/bin. It validates and reformats files that already use xcproj. It is not a converter from pbxproj. For CI, think of it as swift-format for your project file. Run xcprojformatter --help to see its options, and consider adding a check to CI so hand edits (from people or AI agents) stay in canonical form.
What about Tuist, XcodeGen, CocoaPods, fastlane?
This is the main reason to wait before switching.
-
Anything that parses
project.pbxprojdirectly needs an update. That includes CocoaPods through thexcodeprojgem, fastlane actions that bump versions or change settings, project generators, and custom scripts. As of the 27.2 release, support is still rolling out. -
The Swift
XcodeProjlibrary already has experimental support. Version 9.17.0 added it on September 17, 2026. Its maintainers say to expect details to change and to check a converted project before committing it. -
Some tools don't support it yet. For example, Capacitor has an open issue because
cap sync iosonly parses.pbxproj. - Generators keep working. Tuist, XcodeGen, Bazel, SwiftPM, and xcconfig-based workflows continue to work unchanged and can adopt the new format at their own pace.
Why now? (Hint: AI agents)
Apple filed its announcement under Coding Intelligence. Apple says directly that the new format makes it easier for coding agents to work with Xcode projects. When an agent (or a human) can change one setting by editing one readable line, instead of updating several ID cross-references, there are far fewer ways to break the project.
Should you switch?
| Your situation | Recommendation |
|---|---|
| Solo dev or small team, everyone on Xcode 27 | Switch now |
Hand-maintained .xcodeproj with frequent conflicts |
Switch, since you gain the most |
| Someone on the team still uses Xcode 26 | Wait |
| CocoaPods, fastlane project edits, or custom pbxproj scripts | Check each tool first |
| You generate projects with Tuist or XcodeGen | Nothing urgent, since your manifest is the source of truth |
Summary
project.xcproj isn't a new build system or a Swift manifest. It's the same project model written in a form people can actually read. Adding a file is one line, a setting is one line, and a merge conflict becomes something you can resolve by reading it.
Further reading
Top comments (0)