01The Instinct You Earn on Big Apps
Spend enough years on large, long-lived iOS codebases, the kind shipped to millions of people and grown by a rotating cast of teams, and you develop a particular kind of paranoia. You know dead code is piling up in there. Whole view controllers nobody routes to anymore, helpers written for a feature that got cut, design tokens defined for a redesign that never shipped. You can feel the weight of it. You just can't prove it, and you certainly can't safely delete it.
That is the whole problem with dead code in Swift. The compiler will happily carry a corpse forever, as long as the corpse is syntactically alive. grep will not find it, because the symbol is referenced somewhere, often in a registration table that is itself never read. "Find Usages" in Xcode shows you one call site and you nod and move on. The code is dead, and every tool you reach for first tells you it is fine.
I tried Periphery on a multi-platform SwiftUI app I was building. Its report gave me a way to investigate unused code, along with plenty of findings that needed review before deletion.
02What It Actually Is
Periphery is an open-source command-line tool that finds unused declarations in Swift: classes, structs, enums, protocols, functions, properties, enum cases, and individual function parameters. Unlike text-based searches, it analyzes references from a compiled project.
Periphery compiles your project. It reads Apple's IndexStore, the index database used by SourceKit and Xcode's "Find Usages," and builds a graph of declarations and references. It walks outward from entry points such as the app delegate, @main, and test cases. Declarations it cannot reach become candidates for review.
That design is the reason it is accurate, and the reason it has one hard rule: your project must build. Periphery cannot analyze code that does not compile, because there is no index to read. If your build is broken, fix that first.
03Getting It Running
Install is a one-liner:
brew install peripheryapp/periphery/periphery
Then, the single best decision you can make on day one, run the guided setup instead of memorizing flags:
periphery scan --setup
It interrogates your project: which .xcworkspace or .xcodeproj, which schemes, which targets to analyze. It runs a full scan with your answers, prints the results, and then offers to write a .periphery.yml so every future run is just:
periphery scan
A real config from one of my projects looks like this:
project: MyApp.xcodeproj
schemes:
- MyApp
targets:
- MyApp
- MyAppKit
retain_public: true
For a Swift Package, it is even less ceremony, no project file, no schemes. Just cd into the package root and run periphery scan. It reads Package.swift and figures the rest out.
That retain_public: true line matters more than it looks, and we will come back to it.
04The First Scan Will Lie to You (a Little)
The first report needs review before you delete anything.
My SwiftUI app scan flagged roughly 150 unused symbols. Many were design-system tokens in Shared/Design/: colors, spacing values, and type styles defined for completeness but not yet wired to views. I kept those while reviewing the other findings.
Swift has a half-dozen runtime features that a static index physically cannot see, and every one of them produces a false positive. Before you delete a single line, you need to know the suspects:
Objective-C runtime usage. Anything reached through the ObjC runtime, @IBAction, @IBOutlet, #selector(...), KVO, target/action, looks unreferenced to a Swift index, because the "call" is a string resolved at runtime. Periphery has flags for exactly this: --retain-objc-accessible and --retain-objc-annotated.
Codable. Your CodingKeys enum and the properties only touched inside encode(to:)/init(from:) look unused, because synthesized conformances do the reading invisibly. Reach for --retain-codable-properties and your decode models stop screaming.
Storyboards and XIBs. Connections wired in Interface Builder are runtime bindings. Periphery handles most IB linkage, but verify before trusting.
SwiftUI previews and property wrappers. PreviewProvider and some wrapper-backed storage can read as unused depending on configuration.
Anything reflective, or resolved dynamically. #selector, string-keyed APIs, NSClassFromString, dynamic member lookup, dependency-injection containers that resolve by type at runtime. If a framework calls your code by name rather than by symbol, the index never sees the edge, and Periphery calls the target dead when it is very much alive.
Symbols used only by a target you did not scan. This one bit me. The app has a tvOS target, and a symbol referenced only from that target looks unused to a scan scoped to the main app. Multi-platform apps with a TV, watch, or widget extension are full of this trap. Either scan every target or treat anything platform-specific as guilty-until-proven.
I scanned, reviewed the findings, and adjusted the --retain-* flags and target list before considering deletion. Once I accounted for the design tokens, TV target, and dynamic lookups, only a small fraction of the original 150 looked safe to remove.
When you know a specific declaration is fine but Periphery cannot, annotate it in place and move on:
// periphery:ignore
func invokedByTheRuntime() { }
// periphery:ignore:parameters unusedButRequiredBySignature
func delegateCallback(unusedButRequiredBySignature: Int) { }
05What It Finds That You Did Not Expect
The unused classes and functions are the obvious wins. The findings that genuinely changed how I write Swift were the subtler categories:
Unused function parameters. A parameter you thread through three layers and never read. Periphery flags every one. Deleting them simplified call sites I had stopped noticing years ago.
Redundant public accessibility. With retain_public: true, Periphery assumes anything public is an external API and leaves it alone. Turn that off inside an app target (where nothing is a real public API) and it will tell you exactly which declarations are marked public or open but only ever used internally. Tightening those is free architectural hygiene, smaller module interfaces, faster incremental builds, clearer intent.
Redundant protocol conformances. A type that declares : Equatable but is never actually used as Equatable anywhere. The conformance is dead weight, and Periphery names it.
Assign-only properties. Periphery also flags properties that are written but never read. Some of these were left behind by partly removed features.
06Why I Did Not Just Delete It All
I kept the report for review rather than deleting everything it flagged. Three details of this project shaped that decision.
First, the linker already handles much of the binary-size concern. I build with DEAD_CODE_STRIPPING=YES, so truly unreferenced symbols are stripped from the shipping binary. The 150 findings had little effect on app size. I was using Periphery to improve source-level maintainability, reducing the code a person has to read and reason about.
Second, the false-positive risk was real and concentrated. Between the tvOS target and a few dynamic lookups, a non-trivial slice of the report was working code that only looked dead. Auto-deleting a batch and "just running the tests" would have shipped a broken TV build that the app-target tests never exercise.
Third, the design tokens were intentional. An exhaustive design system is supposed to define the full palette and scale, not only the values currently bound to a screen. Deleting "unused" tokens to satisfy a tool is letting the tool design your system. That is backwards.
The result was a reviewed, annotated report. I retained the intentional design tokens, included the TV target in the scan, annotated dynamic lookups, and left the remaining candidates flagged for confirmation before removal.
When you do delete, do it iteratively, because dead code hides dead code: removing one unused method orphans the private helper only it called. Run, delete a confident batch, build green, then scan again, each pass exposes the next layer down. But "delete confidently" and "delete everything the tool flagged" are not the same instruction.
07Turning It Into a Gate
Once the existing findings have been reviewed, a CI check can catch new unused declarations.
Periphery returns a non-zero exit code on findings when you ask it to:
periphery scan --strict --format github-actions
--strict turns "found something" into a failed build. --format controls how findings are surfaced, and the options are well chosen:
xcode, emits warnings that show up inline in the Xcode issue navigator.github-actions, annotations on the exact lines in a pull request.json/csv/checkstyle, for piping into whatever dashboard you already run.
A couple of hard-won operational notes:
Scan on a clean index. Stale DerivedData produces stale results. In CI, pass --clean-build so the index is rebuilt from scratch. It is slower, it is correct.
Commit your .periphery.yml. The whole team and the CI runner must scan identically, or you get findings that appear and vanish depending on whose machine ran last. The config file is the contract.
The payoff: once the baseline is clean and --strict is wired into the PR check, new dead code cannot merge. Someone adds a helper "for later," never wires it up, and the build goes red with a line-level annotation pointing straight at it. The corpse never makes it into main.
08What I Would Use It For
Periphery is not magic and it is not instant. It builds your whole project, so it is not fast. The first report will scare you with false positives until you learn which --retain-* flags your codebase needs. And it is Swift-and-Apple-platforms only, this is not your tool for a Kotlin or TypeScript codebase.
The graph is what makes Periphery useful to me. Text search and Xcode's "Find Usages" help investigate individual symbols; Periphery gives me a project-wide starting point for that investigation.
If you work on a Swift codebase of any size, run it this week. Budget an afternoon to tune the retain flags and target list, separate the genuinely-dead from the merely-invisible, and then wire --strict into CI so new dead code cannot merge. Do not let DEAD_CODE_STRIPPING lull you into thinking there is nothing to do, the linker keeps your binary lean, but only you keep your source readable.
On this SwiftUI app, the most useful output was the report itself. It showed where to investigate, which configuration gaps to fix, and which declarations needed a human decision before removal.
Periphery is open-source under the MIT license. Setup and flag reference: github.com/peripheryapp/periphery.