# 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.

Source: https://stringlane.app/docs/cli/unused-keys
Last updated: 2026-09-16

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.

```sh
stringlane scan
```

It 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.strings` and 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

| Family | Extensions | Call shapes |
|---|---|---|
| Dart | `.dart` | `AppLocalizations.of(context)!.key`, `context.l10n.key`, `S.of(context).key` |
| JavaScript | `.js` `.jsx` `.ts` `.tsx` `.mjs` `.cjs` `.mts` `.cts` `.vue` `.svelte` | `t('key')` and its `i18n.t` / `i18next.t` / `$t` / `this.$i18n.t` spellings, plus `` |
| Swift | `.swift` `.m` `.mm` | `NSLocalizedString("key", …)`, `String(localized: "key")`, and SwiftGen's `L10n.some.path` accessors |
| Android | `.kt` `.kts` `.java` `.xml` | `R.string.key`, and `@string/key` in layouts and manifests |

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:

```sh
stringlane check --fail-on unused_key
```

To skip the scan entirely:

```sh
stringlane check --no-scan
```

Passing 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:

```sh
stringlane scan --ignore-key-pattern 'errors.*'
```

Make it permanent in `stringlane.yaml`:

```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`](/docs/key-metadata-storage) on the other four formats. The desktop app writes it from the Key Metadata panel; see [Find keys your code no longer uses](/docs/find-unused-keys).

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:

1. **Confirm each key yourself.** Search for it, including for the runtime spelling the report names when it differs from the merged one.
2. **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.
