How do static and dynamic linking differ for an app, what do they cost at launch and in size, and what are duplicate symbols?

16.3.1 · L5 · Interview

Predict first

A package declares one static and one dynamic library product. Which library files does the build produce?

// === Package.swift ===
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
    name: "Lk",
    products: [
        .library(name: "Stat", type: .static, targets: ["Stat"]),
        .library(name: "Dyn", type: .dynamic, targets: ["Dyn"]),
        .executable(name: "tool", targets: ["Tool"]),
    ],
    targets: [
        .target(name: "Stat"),
        .target(name: "Dyn"),
        .executableTarget(name: "Tool", dependencies: ["Stat", "Dyn"]),
    ]
)
// === Sources/Stat/S.swift ===
public func s() {}
// === Sources/Dyn/D.swift ===
public func d() {}
// === Sources/Tool/main.swift ===
import Stat
import Dyn
s(); d(); print("ok")
  • libStat.a and libDyn.dylib
  • two .dylib files
  • two .a files

Predict first

Two static libraries define the same exported symbol and are both linked into one tool. What happens?

// === Package.swift ===
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
    name: "Dup",
    products: [.library(name: "L1", type: .static, targets: ["L1"]), .library(name: "L2", type: .static, targets: ["L2"])],
    targets: [.target(name: "L1"), .target(name: "L2"), .executableTarget(name: "T", dependencies: ["L1", "L2"])]
)
// === Sources/L1/a.swift ===
@_cdecl("shared_symbol") public func shared1() -> Int32 { 1 }
// === Sources/L2/b.swift ===
@_cdecl("shared_symbol") public func shared2() -> Int32 { 2 }
// === Sources/T/main.swift ===
import L1
import L2
print(shared1(), shared2())
  • It builds and prints 1 2
  • The link fails: duplicate symbol
  • It prints 2 2

Mental model. Static linking is baking the spices into the dish. Dynamic is serving the spice jar next to the plate: it costs a trip to fetch it, but several dishes can share one jar, and two jars of the same spice at one table cause arguments over which to use.

Static linking copies the needed code from a library (.a, or a static framework) into the final executable at build time. There is nothing to load at launch, the linker can strip unused code (dead-stripping) and optimise across the boundary, and the app is one self-contained image. Dynamic linking keeps the code in a separate image (.dylib or a dynamic framework) that dyld loads, maps and binds when the app starts (or on demand). In a SwiftPM package a library product with type: .static builds libStat.a and one with type: .dynamic builds libDyn.dylib; left automatic, SPM chooses (usually static for an app’s dependencies). The trade-offs: launch time grows with the number of dynamic images, because each costs mapping, symbol binding and initialiser work (an app with forty dynamic frameworks pays heavily; the launch topic has the detail), so prefer static for many small internal modules. Sharing is the case for dynamic: if an app and its extensions (widgets, share extension) use the same large library, a dynamic framework avoids duplicating its code in each executable, and a dynamic framework can be swapped independently (rare on iOS, where all images ship in the bundle). Mergeable libraries (Xcode 15) let you build dynamic in debug for fast incremental builds and merge into the app in release for static-like launch behaviour.

The classic hazard is duplicate symbols. If two static libraries both contain the same symbol (the same global function or Objective-C class, for example because a dependency was linked in twice), the linker reports “duplicate symbol '_shared_symbol'” and fails. With dynamic libraries it is subtler and worse: both images load, Objective-C logs “Class X is implemented in both A and B. One of the two will be used. Which one is undefined” and which one runs is unspecified, producing baffling bugs, plus doubled singletons and static state (two copies of a “shared” cache, one per image). Typical causes: the same dependency statically linked into the app and into a dynamic framework, a static library linked into several dynamic frameworks that the app also links, or two forks of one library. Fixes: link each dependency in exactly one place (make shared code a single dynamic framework, or make everything static into the app), use a package manager that deduplicates, inspect with nm and otool -L, and use the linker’s -ObjC and -force_load flags knowingly (they pull in every Objective-C class and can create duplicates). Measure with the App Launch instrument rather than guessing.

A static product becomes an archive (.a); a dynamic one becomes a .dylib that is loaded at run time.

The linker sees one symbol defined twice and refuses to link.

Common traps

  • Making every internal module a dynamic framework. Launch slows with each image; use static for internal modules unless sharing is the goal.
  • Linking the same library into the app and into a dynamic framework. It creates duplicate classes and state; link it once.

They might follow up with

  • When is dynamic linking worth it? When an app and its extensions share a large library, or with mergeable libraries in debug builds.
  • What does a duplicate symbol look like? A link error for static duplicates, and an undefined “implemented in both” warning for dynamic ones.

Related questions

  • 16.3.2 Why must the module dependency graph be acyclic, how do you layer it, and what does its shape do to build…
  • 16.2.2 What is an XCFramework, what do module maps and library evolution have to do with it, and when do you use a…
  • 6.4.3 Your app launches slowly. How do you find out why and what do you cut?

Why must the module dependency graph be acyclic, how do you layer it… →