Identifying the Code Behind an Apple Crash
This is the workflow I use to go from an Apple crash report to the Swift source line that likely caused the crash.
The crash report is the important file. It is usually an .ips file copied from
a device, downloaded from App Store Connect, exported from Xcode Organizer, or
sent by a tester. If the crash came with a feedback JSON file, that JSON is
useful metadata, but the stack trace and binary images live in the .ips.
The same process works for any Apple platform app as long as you have the matching dSYM. It also works whether you have one crash report or several reports for the same problem.
Files You Need
Create a small working folder and put the files for the investigation in it:
mkdir -p "$HOME/Downloads/crash-investigation"
cd "$HOME/Downloads/crash-investigation"The folder might contain:
feedback.json
AppName-2026-06-21-212351.ips
AppName-2026-06-21-212557.ipsThere can be one .ips file or many. There can be one feedback JSON file, many
JSON files, or none.
You also need the dSYM for the exact build that produced the crash. Local Xcode archives are the first place to look. If the app was distributed through App Store Connect, you can also download the dSYM for that build from App Store Connect.
Existing Tools
You do not have to parse and symbolicate crash reports by hand every time.
Start with Apple's tools when they work:
- Xcode can open and symbolicate crash reports when it can find the matching archive or dSYM.
dwarfdumpchecks whether a dSYM UUID matches the crashed binary.atosmaps raw addresses back to symbols when you provide the right dSYM, architecture, load address, and addresses.
Apple's documentation covers both the JSON crash report format and adding symbol names to crash reports.
There are also community tools:
- MacSymbolicator is a Mac app with a CLI. It supports
.crashand.ipsreports, can print UUIDs, and can use one or more dSYM paths. - AppleCrashScripts is a small Swift-based toolset that
can convert modern JSON
.ipsreports and symbolicate them. - pycrashreport is a Python parser for Apple crash reports. It is useful when you want a library or CLI that extracts the important crash fields into a clearer view.
For a one-off crash, a symbolicator app may be faster. The manual commands in this post are still useful when you need to understand what the tool is doing, debug a failed symbolication, or automate a narrow workflow for several reports.
1. Read the Metadata
If you have feedback JSON files, inspect them first:
for json in ./*.json; do
[ -e "$json" ] || continue
echo "== $json =="
jq . "$json"
doneThe feedback JSON is not the full crash report. It is useful for fields such as:
- app version and build number
- device model
- OS version
- app uptime
- tester comment
- timestamp
Then compare that metadata with the .ips headers:
for ips in ./*.ips; do
[ -e "$ips" ] || continue
echo "== $ips =="
head -40 "$ips"
doneYou are trying to confirm that the files describe the same app build and event:
CFBundleShortVersionString: 1.2.3
CFBundleVersion: 456
device: iPhone16,2
OS: iPhone OS 18.5
app: com.example.AppNameTester comments can be useful clues, but they are optional. If you do not have
feedback JSON, skip this part and start from the .ips.
2. Parse the .ips Structure
An .ips file often contains:
- a one-line JSON header
- the full crash JSON object
Some exports contain only the crash JSON object.
The script below uses only Python's standard library: json, sys, and
pathlib. No crash-report package is required because an .ips crash report is
JSON once you skip the optional one-line header.
python3 - ./*.ips <<'PY'
import json
import sys
from pathlib import Path
def load_report(path: Path):
text = path.read_text()
try:
return json.loads(text)
except json.JSONDecodeError:
return json.loads(text[text.find("\n") + 1 :])
for arg in sys.argv[1:]:
path = Path(arg)
if not path.exists():
continue
report = load_report(path)
print()
print(path.name)
print("incident:", report.get("incident"))
print("procName:", report.get("procName"))
print("bundleID:", report.get("bundleID"))
print("exception:", report.get("exception"))
print("termination:", report.get("termination", {}).get("namespace"))
print("reason:", report.get("termination", {}).get("reasons", [""])[0])
print("faultingThread:", report.get("faultingThread"))
PYThe output gives you the crash category before you look at individual frames:
exception: EXC_BAD_ACCESS / SIGSEGV
termination namespace: SIGNAL
reason: Segmentation fault: 11
faultingThread: 4Different reports have different exception and termination values. A memory access crash, a force-unwrap crash, a watchdog kill, and an out-of-memory termination all lead you toward different evidence. Treat the crash reason as a triage clue, not as the final diagnosis.
3. Find the App Image UUID and Load Address
The dSYM lookup depends on the app binary UUID. That UUID comes from the
usedImages array in the .ips file. Each entry in usedImages describes one
binary image loaded into the crashed process:
name: binary nameuuid: binary UUIDbase: load address for that imagearch: CPU architecture, when present
The app binary is often usedImages[0], but do not rely on that blindly. The
safer approach is to print the images and find the entry whose name matches
the report's procName.
python3 - ./*.ips <<'PY'
import json
import sys
from pathlib import Path
def load_report(path: Path):
text = path.read_text()
try:
return json.loads(text)
except json.JSONDecodeError:
return json.loads(text[text.find("\n") + 1 :])
for arg in sys.argv[1:]:
path = Path(arg)
if not path.exists():
continue
report = load_report(path)
proc_name = report.get("procName")
print()
print("==", path.name, "==")
print("procName:", proc_name)
for index, image in enumerate(report.get("usedImages", [])):
name = image.get("name")
marker = " <-- likely app image" if name == proc_name else ""
print(
index,
name,
"uuid=" + str(image.get("uuid")),
"base=" + hex(image.get("base", 0)),
"arch=" + str(image.get("arch")),
marker,
)
PYExample output:
procName: AppName
0 AppName uuid=12345678-90ab-cdef-1234-567890abcdef base=0x1048ac000 arch=arm64 <-- likely app image
1 UIKitCore uuid=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee base=0x1a0000000 arch=arm64
2 libswiftCore.dylib uuid=bbbbbbbb-cccc-dddd-eeee-ffffffffffff base=0x1b0000000 arch=arm64For the app image, keep:
app image index: 0
uuid: 12345678-90ab-cdef-1234-567890abcdef
base: 0x1048ac000
arch: arm64The absolute address for an app frame is:
absolute address = app image base + frame.imageOffset4. Find the Matching dSYM
The dSYM must match the app binary UUID exactly. Search local Xcode archives first:
find "$HOME/Library/Developer/Xcode/Archives" -name '*.dSYM' -printCheck UUIDs:
dwarfdump --uuid '/path/to/AppName.app.dSYM'To search for a known UUID:
CRASH_UUID="12345678-90ab-cdef-1234-567890abcdef"
find "$HOME/Library/Developer/Xcode/Archives" -name '*.dSYM' -print0 |
while IFS= read -r -d '' dsym; do
if dwarfdump --uuid "$dsym" | grep -qi "$CRASH_UUID"; then
echo "$dsym"
fi
doneA matching dSYM prints the same UUID:
UUID: 12345678-90AB-CDEF-1234-567890ABCDEF (arm64) .../AppName.app.dSYM/Contents/Resources/DWARF/AppNameIf you cannot find a local dSYM, download the dSYM for the exact build from the place where you distributed the app.
5. Extract App Frames from Interesting Threads
Start with:
faultingThread- the main thread
- threads whose frames mention the same subsystem
- threads blocked on locks, semaphores, file IO, networking, or database work
This script prints app frames as absolute addresses for interesting threads. Set
APP_IMAGE_INDEX to the image index you found in step 3.
APP_IMAGE_INDEX=0 python3 - ./*.ips <<'PY'
import json
import os
import sys
from pathlib import Path
def load_report(path: Path):
text = path.read_text()
try:
return json.loads(text)
except json.JSONDecodeError:
return json.loads(text[text.find("\n") + 1 :])
app_image_index = int(os.environ["APP_IMAGE_INDEX"])
for arg in sys.argv[1:]:
path = Path(arg)
if not path.exists():
continue
report = load_report(path)
base = report["usedImages"][app_image_index]["base"]
faulting_thread = report.get("faultingThread")
print()
print("==", path.name, "==")
for index, thread in enumerate(report.get("threads", [])):
queue = thread.get("queue")
frames = thread.get("frames", [])
has_app_frame = any(
frame.get("imageIndex") == app_image_index for frame in frames
)
interesting = (
thread.get("triggered")
or index == faulting_thread
or queue == "com.apple.main-thread"
or has_app_frame
)
if not interesting:
continue
print()
print("thread", index, "queue:", queue, "triggered:", thread.get("triggered"))
for frame in frames[:90]:
if frame.get("imageIndex") == app_image_index:
print(frame["imageOffset"], hex(base + frame["imageOffset"]))
elif frame.get("symbol"):
print(frame["symbol"])
PYThe non-app symbols around your app frames matter. For example:
objc_exception_throw
swift_unexpectedError
dispatch_semaphore_wait
__psynch_mutexwait
sqlite3_stepThose names tell you whether you are probably looking at an exception, Swift runtime trap, lock contention, database work, IO, or some other category.
6. Symbolicate App Addresses with atos
Use:
- the dSYM DWARF file
- the architecture from the app image, usually
arm64 - the app image load address from the
.ips - the absolute addresses from the previous step
The dSYM path passed to atos is the DWARF file inside the .dSYM, not just
the .dSYM directory:
atos \
-arch arm64 \
-o '/path/to/AppName.app.dSYM/Contents/Resources/DWARF/AppName' \
-l 0x1048ac000 \
0x1048da4b4 0x1048d7e88 0x1048d8190Symbolicated output should point back to your app's functions and, when debug information is available, source files and line numbers:
AppFeatureReducer.reduce(into:action:) (AppFeatureReducer.swift:153)
closure #1 in AppDetailView.body.getter (AppDetailView.swift:88)
static NetworkClient.liveValue.getter (NetworkClient.swift:42)That changes the investigation from "the app crashed somewhere in Apple frameworks" to a source-level hypothesis you can check.
7. Read the Stack Bottom-Up
The top app frame tells you where execution ended. The lower frames often explain how the app got there.
For a user-action crash, the lower frames might show a button, menu, or gesture:
ButtonAction.callAsFunction()
UIAction performWithSender
closure #1 in AppDetailView.body.getter
AppFeatureReducer.reduce(into:action:)For a background crash, the lower frames might show a queue, task, database callback, network callback, notification, or timer:
_dispatch_call_block_and_release
closure #1 in SyncClient.start()
DatabaseWriter.write()
AppStore.save(_:)Read upward from the oldest app frame toward the newest app frame and ask:
- What event started this work?
- Which app code handled it?
- Which state, dependency, database, file, or network operation was involved?
- Does the exception or termination reason match that path?
- Do other threads support the same story?
8. Map Symbolicated Frames to Source
Use rg to find the implicated code:
rg -n "AppFeatureReducer|AppDetailView|NetworkClient|save\\(" /path/to/your/source -g '*.swift'Then inspect the surrounding code, not only the exact line printed by atos.
Useful things to look for include:
- force unwraps and forced casts
- assumptions about optional data
- state mutation during view rendering
- work started from callbacks or notifications
- dependency initialization inside hot paths
- blocking work on the main thread
- locks, semaphores, and synchronous dispatch
- database writes or file IO during UI updates
The right fix should explain the symbolicated frames, the exception or termination reason, and any available user feedback.
9. Fix the Likely Cause, Not Just the Top Frame
The top frame is a starting point, not proof. A good fix should address the code path implied by the whole stack.
For example:
- If the crash is
EXC_BAD_ACCESS, check ownership, unsafe pointers, C APIs, object lifetimes, and concurrency boundaries. - If the crash is a Swift trap, check force unwraps, forced casts, failed
preconditions, array indexes, and
try!. - If the app was killed by a watchdog, look for main-thread blocking, lock contention, synchronous dispatch, or long-running work during app lifecycle callbacks.
- If the app was terminated for memory, look for large allocations, image/video processing, caches, and repeated work across threads.
Make the smallest architecture-consistent change that explains the report. Then build the touched targets and, when possible, reproduce the triggering path.
10. Verify
Build the touched targets for your app:
cd /path/to/your/project
swift buildIf the app is built with Xcode, build the relevant scheme too:
xcodebuild build \
-scheme AppName \
-destination 'generic/platform=iOS' \
-quietIf the build fails in unrelated generated code or a third-party package before your local code compiles, record that separately. The important thing is to keep the crash fix and unrelated build blockers distinct.
Checklist
Use this checklist for future Apple crash reports:
- Put the
.ipsfiles and any related feedback JSON files in one folder. - Read feedback JSON with
jq, if available. - Read
.ipsheaders and crash objects. - Match app version, build, device, OS, and timestamp.
- Identify exception and termination reason.
- Print
usedImagesand find the image whosenamematchesprocName. - Extract that app image's UUID, base address, image index, and architecture.
- Find the exact matching dSYM with
dwarfdump --uuid. - Convert app frame offsets to absolute addresses.
- Symbolicate with
atos -l <base>. - Read user-action or background-work stacks bottom-up.
- Compare blocked or related threads for shared locks, IO, or async work.
- Map symbolicated frames to source with
rg. - Form a hypothesis that explains the stack, crash reason, and available user feedback.
- Make the smallest architecture-consistent fix.
- Build the touched targets and record any unrelated build blockers.