Dead code detector for Dart and Flutter. Finds declarations that are never referenced — classes, functions, methods, fields, constants, enum values — and can remove them for you.
"Ciach!" — pronounced /t͡ɕax/ — is Polish for the sound of a clean chop, the noise a knife makes right before something falls off.
Install it globally for a ciach command everywhere, in ~/.pub-cache/bin:
dart pub global activate ciachOr add it as a dev dependency, which pins the version for the team and CI:
dart pub add --dev ciach
dart run ciachExamples below show bare ciach …; prefix them with dart run for the second.
ciach # current package
ciach path/to/package # another package
ciach --no-public -f json # private-only, as JSON
ciach -f github --set-exit-if-changed # CI: annotations, non-zero on finds
ciach --remove # delete findings, asks first
ciach --remove --force # …without asking
ciach --verbose # explain each step| Option | Default | Description |
|---|---|---|
[path] |
. |
Package root to analyze. |
-h, --help |
— | Print usage information. |
--config <path> |
auto | Read settings from this YAML file instead of the auto-discovered one. See Configuration file. |
--no-config |
off | Ignore the config file, even if one is found. |
--[no-]public |
on | Report unused public declarations too. Disable to report only private (_-prefixed) ones. |
--[no-]generated |
off | Scan generated files (*.g.dart, *.freezed.dart, *.mocks.dart, …). |
--[no-]overrides |
off | Report @override members too. Off by default — see limitations. |
--[no-]operators |
off | Report operator overloads (operator +, operator ==, …) too. Off by default — see limitations. |
--[no-]unused-union-members |
off | Also flag a (sealed) supertype member matched only by type patterns, never constructed. Report-only — never touched by --remove. |
--[no-]report-tojson |
off | Report an otherwise-unused toJson() serialization hook too. Off by default — jsonEncode dispatches to it dynamically. |
--set-exit-if-changed |
off | Exit with status 1 when anything is found (for CI). Named after dart format. |
--[no-]fail-public |
on | Count unused public declarations toward the exit code (with --set-exit-if-changed). --no-fail-public reports them but fails only on private findings. |
--remove |
off | Remove unused declarations after reporting them. Prompts for confirmation first. |
--force |
off | Skip the confirmation prompt for --remove. Requires --remove. |
-e, --exclude <glob> |
— | Skip files matching the glob (repeatable). |
-i, --include <glob> |
— | Only scan files matching the glob (repeatable). |
--generated-suffix <suffix> |
— | Extra filename suffix (with leading dot) to treat as generated and skip, on top of the built-in set; repeatable. Ignored when --generated is set. |
-k, --kinds <list> |
all | Restrict to kinds: class, mixin, interface, enum, extension, function, method, constructor, field, property, getter, setter, variable, constant, enum-value. |
-f, --format <fmt> |
text |
text, json, or github (GitHub Actions ::warning annotations). |
-j, --concurrency <n> |
16 |
Reference queries kept in flight against the analysis server. |
--[no-]color |
auto | Colorize text output. |
--[no-]progress |
auto | Show scan progress on stderr. |
-v, --verbose |
off | Explain what's happening on stderr. See Verbose mode. |
--dart <path> |
current SDK | Path to the dart executable to launch the server with. |
Exit codes: 0 success, 1 unused found with --set-exit-if-changed, 2
usage or analysis error.
Every option above can live in a ciach.yaml in the package root, keyed by its
long name minus the --, plus path for the positional argument:
public: false # --no-public
exclude: ['test/**', 'tool/**'] # repeatable options take a list, or a bare string
kinds: [class, function, method]
format: github
set-exit-if-changed: trueCommand line beats config file beats default, even when the flag matches the
default (ciach --public overrides public: false), and a repeatable option on
the command line replaces the config's list rather than adding to it. Unknown
keys and wrong-typed values are usage errors naming the file and the key.
Discovery looks for that one file name in the analyzed package root, never in a
parent, so each package in a monorepo owns its config. --config <path> reads
one from elsewhere; --no-config ignores a discovered one; the two can't be
combined.
-v narrates the run on stderr, with elapsed times: the config file read and
what it set, every setting and the layer it came from, each scan phase, anything
the definition check rescued, and what --remove touches.
$ ciach -v
[ 0.0s] Read config from ciach.yaml.
[ 0.0s] It sets 2 options:
[ 0.0s] public: false
[ 0.0s] exclude: test/**
[ 0.0s] Settings for this run:
[ 0.0s] path: /home/me/pkg (command line)
[ 0.0s] public: false (config file)
[ 0.0s] exclude: test/** (config file)
[ 0.0s] concurrency: 16 (default)
[ 0.0s] color: true (auto-detected)
…
[ 0.1s] Starting Dart analysis server…
[ 0.3s] Collecting declarations from 13 file(s)…
[ 0.5s] Scanned 13 file(s) and checked 44 declaration(s) in 478ms: 4 unused, 1 referenced only from doc comments.It all goes to stderr, so ciach -v -f json | jq still works. Reach for it when
a config file seems not to apply, or to find the phase eating the time. It
supersedes --progress, whose self-overwriting line would fight with it.
A dartdoc [Xxx] link resolves to a real declaration, so the analysis server
counts it as a reference — but a comment mentioning something isn't the same as
code calling it. Declarations with no code references are reported separately,
in every format:
lib/greeting.dart
15:6 function danglingFunction (public)
Referenced only from doc comments — not counted as unused, never removed:
lib/greeting.dart
40:6 function docOnlyMentioned (public)
These never count toward --set-exit-if-changed, are never touched by
--remove, and get a ::notice rather than a ::warning in -f github. Drop
the doc link to have one reported as properly unused.
- run: dart pub get
- run: dart run ciach -f github --set-exit-if-changedEach finding becomes a ::warning annotation inline on the PR diff. Run it from
the repository root so paths resolve; when scanning a sub-package (ciach -f github app), the scan path is prepended automatically.
For a library or workspace package whose public API is legitimately "unused"
from its own perspective, add --no-fail-public to still surface those
findings while gating the job on unused private declarations only:
- run: dart run ciach -f github --set-exit-if-changed --no-fail-public--remove deletes every reported declaration — doc comment and annotations
included — after showing what it is about to remove and asking:
Found 4 unused declarations in 2 files (scanned 6 files, 44 declarations, 0.5s).
Remove 4 unused declarations? [y/N] y
Removed 4 unused declarations from 2 files.
--force skips the prompt (and is a usage error on its own); with no terminal to
confirm on and no --force, nothing is removed. Run dart format afterward:
removal is conservative about what it deletes — an ambiguous int a = 1, b = 2;
is left alone unless every declarator is unused — but not about spacing.
Removal acts on whatever the finder reports, so it inherits the same
false-positive risk, which --overrides and --operators widen considerably.
Doc-only findings are never included. Review the diff, as
you would after any automated refactor.
Each of these is a known source of false positives; the flag opts back in at that cost.
| Skipped | Why | Flag |
|---|---|---|
main |
the entry point is never unused | — |
@override members |
often reached polymorphically or by a framework (build, initState, ==, …), which a name-based search misses |
--overrides |
| Operator overloads | the server doesn't resolve a + b back to the declaration, so a used operator is flagged every time |
--operators |
call methods |
implicit-call syntax (obj(…)) is unresolvable the same way |
— |
@pragma('vm:entry-point') |
reachable from native code or reflection | — |
| Generated files | by filename convention and the GENERATED CODE - DO NOT MODIFY BY HAND banner. Still opened during analysis, so a declaration used only from a .g.dart isn't misreported |
--generated |
toJson() |
jsonEncode(obj) calls it by dynamic dispatch, leaving no source-level reference |
--report-tojson |
| Type parameters | always "used" within their scope | — |
dartdoc [Xxx] links |
not a code reference; reported as doc-only instead of hidden | — |
Private constructors are not skipped: an unused ClassName._ is dead code
like any other. A sole zero-parameter ClassName._() — the classic
prevent-instantiation marker — is reported with a hint suggesting abstract final class instead. See example/ for a runnable demonstration of each case.
This is a static, reference-based heuristic, so review its output rather than deleting blindly:
- A library package's public API is legitimately unused from inside the
package. Prefer
--no-publicthere, or treat public findings as advisory. - Reflection, dynamic invocation, and names referenced only from generated code you excluded are invisible to a reference search.
- Entry points other than
main(isolate entry points, plugin registrants) need excluding or@pragma('vm:entry-point'). - A package that doesn't analyze cleanly (missing
pub get, errors) yields incomplete references.
Runtime is the analysis server's, not the tool's. It analyzes the whole package
once per run — tens of seconds for a large Flutter app, and unskippable, since
incomplete analysis means wrong reference counts — then answers one
textDocument/references per declaration through a pool of -j (default 16),
with scanned files kept open so its resolved-unit cache stays warm.
The lever is how much you ask for. --no-public is by far the cheapest mode:
private declarations are library-scoped, so each query searches one library
instead of the whole workspace, and it surfaces the highest-confidence dead code
anyway. --include/--exclude narrow the scan while still counting references
from everywhere. dart pub global activate compiles ahead of time, so there's no
JIT warmup per run.
The finder is also available programmatically:
import 'package:ciach/ciach.dart';
final result = await Ciach(
FinderOptions(rootPath: 'path/to/package', includePublic: false),
).run();
for (final decl in result.unused) {
print('${decl.filePath}:${decl.line} ${decl.qualifiedName}');
}dart pub get
dart analyze
dart test # spins up a real analysis server against the example/ packageThe implementation lives under lib/src/; the CLI entry point is bin/.
Licensed under the Apache License, Version 2.0. See LICENSE.
This package is built with 💙 by LeanCode. We are top-tier experts focused on Flutter Enterprise solutions.
- Creators of Patrol – the next-gen testing framework for Flutter.
- Battle-Tested – we run
ciachacross our own Flutter and Dart codebases to keep them free of dead code. - Full-Cycle Product Development – we take your product from scratch to long-term maintenance.

