Skip to main content

Static Linking in Xcode

How static linking works in Xcode — what happens at build time, how static libraries and XCFrameworks are merged into your binary, and the trade-offs you actually care about.

What is Static Linking?

Static linking is the process of copying compiled code from a library directly into your app’s binary at build time. By the time your .ipa hits a user’s device, every function your app calls from a static library is already embedded inside the single MyApp Mach-O executable — no separate file, no runtime lookup.

The linker (ld) is the tool that performs this merge. It resolves every symbol reference (every function call, every global variable) against the object files and static archives it has been given, then writes a single output binary.


Key Concepts

Object Files and Archives

The build pipeline has two steps before linking.

  1. The compiler (swiftc or clang) turns each source file into an object file (.o). An object file is compiled machine code for one translation unit — it contains symbols it defines and references to symbols it still needs.
  2. A static library (.a) is just an archive — a bundle of .o files packed together with ar. Nothing is resolved yet; the .a is a library of object files waiting to be consumed by the linker.
Source files → [compiler] → .o files → [ar] → .a archive

App source files → [compiler] → .o files → [ld] → MyApp (Mach-O executable)

Symbol Resolution

The linker’s main job is symbol resolution: matching every undefined symbol in your object files to a definition somewhere in the input.

TermMeaning
Defined symbolA function or variable this object file implements
Undefined symbolA function or variable this object file calls but doesn’t implement — must be found elsewhere
Dead strippingRemoving defined symbols that nothing in the final binary actually references

Xcode enables dead stripping by default (DEAD_CODE_STRIPPING = YES). If you link a 500 KB static library but only call two functions from it, the linker discards the rest. This is one of the main advantages of static linking.


Static Libraries vs. XCFrameworks

FormatExtensionMulti-platform?Notes
Static library.aNo — one arch/platform per fileUse lipo to create a fat binary for multiple archs
XCFramework.xcframeworkYesA wrapper directory containing one .a (or .framework) per platform slice

An XCFramework is not a new kind of linker input — it is a directory structure Xcode uses to pick the right .a or .framework for the current build destination before handing it to the linker.

MyLib.xcframework/
  ios-arm64/
    libMyLib.a          ← linked for device builds
  ios-arm64_x86_64-simulator/
    libMyLib.a          ← linked for simulator builds
  macos-arm64_x86_64/
    libMyLib.a          ← linked for macOS builds

Build Settings That Matter

SettingKeyWhat It Does
Other Linker FlagsOTHER_LDFLAGSPass raw flags to ld-lz, -force_load, -ObjC
Dead Code StrippingDEAD_CODE_STRIPPINGStrip unreferenced symbols (default YES)
Mach-O TypeMACH_O_TYPESet to staticlib to produce a .a from your own target
Link Binary With Libraries(Build Phases)The Xcode UI for adding .a and .xcframework inputs to the linker

-ObjC flag

When a static library contains Objective-C categories, the linker won’t pull in the object file unless something directly references a symbol in it. Categories add methods to existing classes — there’s no direct symbol reference — so the linker silently drops them. -ObjC forces the linker to load all object files from every static library, regardless of whether a symbol was directly referenced.

OTHER_LDFLAGS = -ObjC

If you see “unrecognized selector sent to instance” at runtime after linking a static ObjC library, -ObjC is almost always the fix.

-force_load

A more targeted version of -ObjC. Forces the linker to load every object file from one specific archive, without affecting others.

OTHER_LDFLAGS = -force_load $(BUILT_PRODUCTS_DIR)/libMyLib.a

Creating a Static Library Target

Xcode → New Target → Framework & Library → Static Library

Set the Mach-O Type build setting to Static Library. The output is a .a file in BUILT_PRODUCTS_DIR.

For distribution across platforms, wrap it in an XCFramework:

xcodebuild archive \
  -scheme MyLib \
  -destination "generic/platform=iOS" \
  -archivePath ./archives/ios \
  SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES

xcodebuild archive \
  -scheme MyLib \
  -destination "generic/platform=iOS Simulator" \
  -archivePath ./archives/sim \
  SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES

# Static libraries use -library, not -archive -framework
xcodebuild -create-xcframework \
  -library ./archives/ios.xcarchive/Products/usr/local/lib/libMyLib.a \
  -library ./archives/sim.xcarchive/Products/usr/local/lib/libMyLib.a \
  -output MyLib.xcframework

BUILD_LIBRARY_FOR_DISTRIBUTION=YES emits a .swiftinterface file alongside the binary so consumers on different Swift compiler versions can still use it.


Swift and Static Libraries

Swift adds one complication: the Swift standard library and runtime. When you static-link a Swift library into an app, the app already embeds its own copy of the Swift runtime. The static library’s object files are compiled against the same ABI, so there is no conflict — but you need BUILD_LIBRARY_FOR_DISTRIBUTION=YES to produce a stable interface file that survives compiler version mismatches.

Without it, a library compiled with Swift 5.9 may fail to link against an app built with Swift 5.10 because the compiler-generated mangled names can differ.


Inspecting the Result

# List all symbols defined in a static library
nm -g libMyLib.a

# Show which object files are inside an archive
ar -t libMyLib.a

# Check the architecture slices in a binary or fat archive
lipo -info libMyLib.a

# Show the size of each section in the final binary
size -l -x MyApp

To generate a link map (a text file showing every symbol and which object file it came from), add this to OTHER_LDFLAGS in the Build Settings editor — it is not a shell command:

-map $(DERIVED_FILE_DIR)/link_map.txt

The output file can be several megabytes on a large project.


Trade-offs

Static LinkingDynamic Linking
Launch timeFaster — all symbols resolved at build time, nothing to load at launchSlower — dyld maps and binds every embedded dylib before main() runs
Mach-O executable sizeLarger — all library code merged into one binarySmaller — library code lives in a separate file
Total bundle / IPA sizeSmaller — linker dead-strips unused code across the whole binaryLarger — full dylib embedded and ships in its entirety with no dead stripping across the boundary
MemoryEach process has its own copy of the merged binarySame for your dylibs — no cross-process sharing; only system frameworks share memory via the dyld shared cache
Independent updatesLibrary change always requires app rebuild and resubmissionSame for your own dylibs — only OS system frameworks update independently with the OS
Code sharing across targetsEach binary (app + extensions) gets its own merged copyOne embedded framework can be loaded by the host app and all extensions

For most iOS apps and SDK authors, static linking is the right default. It produces faster launch times, a smaller total bundle, and lets the linker dead-strip code the app never calls. Dynamic frameworks are the right choice when you need to share a significant chunk of code between an app target and its extensions, or when you are distributing a binary SDK to external developers.


Quick Reference

TaskCommand / Setting
Force-load ObjC categoriesOTHER_LDFLAGS = -ObjC
Force-load one specific archiveOTHER_LDFLAGS = -force_load path/to/lib.a
Produce a static library targetMACH_O_TYPE = staticlib
Enable stable Swift ABI for distributionBUILD_LIBRARY_FOR_DISTRIBUTION = YES
List symbols in an archivenm -g libMyLib.a
List object files in an archivear -t libMyLib.a
Check arch sliceslipo -info libMyLib.a
Generate a link mapOTHER_LDFLAGS = -map $(DERIVED_FILE_DIR)/link_map.txt