Find Unused and Undefined Keys
stringlane scan reads your source code and reports keys nothing calls, and keys your code calls that do not exist. What it can see, the four ways a live key lands in that list, and why it refuses rather than guessing.
Last updated
Every project accumulates them. A screen is redesigned, a feature is cut, a key is renamed and the old one stays behind — still translated into every language you support, still counted against your completion percentage, still sent to your AI provider on the next run, and nothing ever tells you it is dead.
stringlane scanIt reads your source code and reports two things.
Defined, not referenced. Keys your project has that no call site names. A cleanup list.
Called in code, not defined. Keys your code asks for that your project has never defined. This is a bug that is already shipping — the screen renders a raw key, or crashes — so it is reported first.
Real output#
35 keys defined · 6 referenced · 29 not referenced · 0 undefined · 1 source file read
Defined, not referenced (29):
appTitle
authForgotPassword
authPasswordHint
cancelOrder
deleteConfirm
errorGeneric
…
What this means: "not referenced" is what the scanner SAW, not a verdict.
A key can be live and still appear here — if it is built by concatenation,
called through a wrapper the scanner does not recognise, named only in a
file type it does not read, or used by a platform that reads your locale
files directly. Check before deleting.
The text report caps both lists so that caveat stays on screen. --json carries them whole, with the file and line of every call site.
It is not a verdict#
"Not referenced" means no call site was found. It does not mean the key is dead. A key will look unused and be perfectly alive if:
- its name is built at runtime —
t('errors.' + code)is invisible to any scanner; - it is called through a wrapper StringLane does not recognise;
- it is named only in a file type the scanner does not read;
- it is read directly out of the locale file by a platform, which is how iOS
InfoPlist.stringsand some Android resources work.
So the list is where a search starts, not a queue to delete from. Every surface that shows it says so beside the list rather than in a footnote.
It refuses rather than guessing. If StringLane read no source files at all, or read them and recognised no translation keys, it tells you that and reports nothing as unused. "I could not read your code" and "every key is dead" produce an identical empty list, and only one of them is safe to act on.
What it reads#
The class name in the Dart patterns is not pinned to AppLocalizations, because output-class is a l10n.yaml option that projects do change. What is pinned is the shape.
The undefined list is narrower than the unused one, on purpose. Undefined keys are computed from exact call sites only. Dart's sites are all recorded as ambiguous, because Foo.of(context).bar is how localizations are read and how Theme.of(context).colorScheme is read. So a Flutter project gets the unused list and not the undefined one — the alternative is reporting every theme property in your app as a missing translation.
The same broadness is why the unused list is trustworthy in the direction that matters: a missed reference is what gets a live key deleted, so the patterns stay wide and accept noise in the other column.
In stringlane check#
check runs the same scan in its normal pass, so you get the finding without going looking for it. Both findings are reported at info:
- your CI does not turn red the day you upgrade;
- a recorded baseline does not flip.
To make it fail the build:
stringlane check --fail-on unused_keyTo skip the scan entirely:
stringlane check --no-scanPassing both is refused rather than accepted. With the scan off nothing can emit unused_key, so the gate would parse, run and never fire — output indistinguishable from a clean project.
Silencing a key that only looks dead#
A whole family that is always built dynamically:
stringlane scan --ignore-key-pattern 'errors.*'Make it permanent in stringlane.yaml:
ignoreKeyPatterns:
- 'errors.*'
- 'InfoPlist.*'
ignoreFilePatterns:
- '**/*.g.dart'The flags add to those lists rather than replacing them, so a one-off run can silence something without editing the file. --ignore-file takes a path glob relative to the project root, and a pattern with no / in it is about a filename, so --ignore-file '*.g.dart' matches a generated file at any depth.
One key, not a family#
A pattern is the wrong tool for a single key that happens to be built at runtime. Mark that key called dynamically instead, which is a per-key record rather than a rule: x-dynamic-key in the @key block on ARB, and in .stringlane/metadata.yaml on the other four formats. The desktop app writes it from the Key Metadata panel; see Find keys your code no longer uses.
The scanner reads it before it reads your patterns, so a key that is both flagged and pattern-matched is reported as silenced by the flag: the per-key decision is the more specific one, and it is where someone who doubts the count has to go. Either way the key is still counted in the summary, and counted separately by cause, so a report reading 0 not referenced because everything was silenced cannot pass for a clean project.
With a coding agent#
The MCP server's read_key_usage tool answers the same question: the call sites of one key, or the project's not-referenced set.
It exists so your agent does not grep your tree and get a worse answer. The merged key a plan spells is not always the string your code contains — a multi-origin project prefixes it and the runtime never sees that prefix — so a search for the wrong spelling reports every key as unused, which looks exactly like a filthy codebase.
describe_keys also tells the agent which of the keys it just documented nothing calls, so it stops writing descriptions for dead ones.
What it costs#
About 75 microseconds per source file. A ten-thousand-file repository takes roughly three quarters of a second, and the scanner stops at twenty thousand files, so the worst case is around a second and a half. Within a single run the second scan re-checks timestamps but does not re-read the files.
Before you delete anything#
Two rules, both learned the hard way:
- Confirm each key yourself. Search for it, including for the runtime spelling the report names when it differs from the merged one.
- Never delete keys in the same change as anything else. A deletion that turns out to be wrong needs to be revertible on its own.
Frequently asked questions
- How do I find translation keys my app no longer uses?
- Run stringlane scan. It reads your source code and lists keys the project defines that no call site names. Treat it as a starting point for a search rather than a delete queue: a key built at runtime, called through a wrapper StringLane does not recognise, or read straight out of the locale file by the platform will appear in that list and still be live.
- Does stringlane check fail my build on unused keys?
- No. check runs the same scan in its normal pass and reports both findings at info severity, so upgrading the CLI does not turn a pipeline red and a recorded baseline does not flip. Pass --fail-on unused_key to opt in, or --no-scan to skip the scan entirely. Passing both is refused, because with the scan off nothing could ever emit that code.
- Why does my Flutter project report no undefined keys?
- Because the Dart call shapes StringLane matches are ambiguous by nature: Foo.of(context).bar is how localizations are read and also how Theme.of(context).colorScheme is read. Undefined keys are computed from exact call sites only, so a project whose sites are all ambiguous gets the unused list and not the undefined one. Reporting otherwise would flag every theme property in your app as a missing translation.
StringLane in Claude Code
Next →Translate With Your Own Provider Key