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.
So when I had a recent project of my own clean enough to experiment on, a multi-platform SwiftUI app I was building, I finally pointed a proper tool at it. Periphery is the one that told me the truth, and then taught me why "delete it all" is the wrong reflex.
02What It Actually Is
Periphery is an open-source command-line tool that finds unused declarations in Swift code: classes, structs, enums, protocols, functions, properties, enum cases, even individual function parameters. It is the Swift answer to ts-prune or a serious dead-code linter, except it is far more honest than anything text-based, because it does not read your code as text at all.
Here is the part that surprises everyone the first time: Periphery compiles your project. It does not grep. It builds your targets, consumes Apple's IndexStore (the same index database SourceKit and Xcode's own "Find Usages" rely on), and constructs a graph of every declaration and every reference between them. Then it picks your real entry points (the app delegate, @main, your test cases) and walks the graph outward. Anything it can never reach is, by definition, dead.
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)
Here is the honest part nobody puts in the README's headline.
My run on the SwiftUI app flagged roughly 150 unused symbols. My first instinct was triumph. My second instinct, the correct one, was suspicion. The bulk of those 150 turned out to be exhaustive design-system tokens living in Shared/Design/, colors, spacing values, and type styles defined for completeness rather than because every single one was wired to a view yet. That is exactly the shape of finding you do not delete on faith.
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.
The workflow that actually works is not "scan once and delete." It is scan, triage, tune the --retain-* flags and target list until the report is boring, then deal with what remains. On this app, once I set the design tokens aside as intentional and accounted for the TV target and dynamic lookups, the genuinely safe-to-remove set was a small fraction of the original 150. The rest was not dead, it was just invisible to a static index.
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. This is the gem. 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. The quiet killer. A property you faithfully set in five places and read in zero. It looks busy. It does nothing. These are often the fossil remains of a feature that got half-removed.
06Why I Did Not Just Delete It All
Here is where most "I found dead code" posts go wrong: they treat the report as a kill list and start mass-deleting. On my SwiftUI app I deliberately did not, and the reasoning is worth spelling out, because it is the actually-senior move.
First, the binary-size argument is mostly already won. I build with DEAD_CODE_STRIPPING=YES. The linker already strips truly unreferenced symbols out of the shipping binary at link time. So the 150 findings were barely affecting app size to begin with. Periphery's value here is not a smaller binary, the linker handles that. Its value is source-level maintainability: less code for a human to read, search, and reason about. Be honest with yourself about which problem you are actually solving, because it changes how aggressive you should be.
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 load-bearing 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.
So the disciplined outcome was not a triumphant mass delete. It was a reviewed, annotated report: tokens marked as intentionally retained, the TV target folded into the scan, dynamic lookups ignored in place, and the genuinely-dead remainder left flagged for a human to confirm before removal. The win was the map, not the bulldozer.
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
Finding dead code once is a cleanup. Stopping it from coming back is the actual win, and that is a CI job.
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.
08The Honest Verdict
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.
But none of that is a real objection, because nothing else does what it does. Text search cannot reason about reachability. Xcode's "Find Usages" shows you one edge at a time and never the global picture. Periphery builds the actual graph and tells you, with citations, what your project can never reach.
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.
The most valuable thing Periphery gave me on that SwiftUI app was not a smaller app. It was an honest map of what my own code was no longer using, and the judgment to act on it carefully instead of all at once. Run it on yours. You will recognize the weight you have been carrying.
Periphery is open-source under the MIT license. Setup and flag reference: github.com/peripheryapp/periphery.