Guides
Remove debug tools from a Flutter release build
Short answer. if (kDebugMode) removes your own Dart code from release builds, but it does not remove a debug package from the app. The package stays in pubspec.yaml, and its native code stays in the binary. To keep a debug tool out of a release build, replace the package with an empty twin that has the same API, before you build. Then check the build to prove the real package is gone.
Why it matters
A debug panel, a network inspector or a log viewer shows request bodies, tokens, feature flags and internal screens. In a store build, that is a security and privacy problem. It also adds size, may add permissions, and gives users a way into screens you never meant to ship.
You will usually want the tools in more than one kind of build:
| Build | Debug tools |
|---|---|
| Debug (your laptop) | Yes |
| Release for QA or beta testers | Yes, so testers can report problems |
| Release for the stores | No |
So "debug mode versus release mode" is not enough. What decides is which build you are making, and that is the flavor. If you have not set up flavors, read Flutter flavors first.
Level 1: gate your own code with kDebugMode
kDebugMode is a compile-time constant. In a release build it is false, and the compiler can drop the code behind it. The Flutter source says so in the docs of the constant: since it is const, "a particular block of code will not be executed", "and hence can be removed".
import 'package:flutter/foundation.dart';
void main() {
if (kDebugMode) {
installLogViewer(); // your own function
}
runApp(const MyApp());
}assert works the same way. Asserts only run in debug mode, and Flutter suggests this pattern for debug-only code:
assert(() {
// Debug-only code here.
return true;
}());This is the right tool for small things. It has two limits:
- It is tied to the build mode. A release build for QA has
kDebugMode == false, so your testers lose the tools too. - It does nothing about packages. See Level 3.
Level 2: choose with a compile-time flag
To keep the tools in a QA release build, use your own constant. --dart-define values are compile-time constants, so the same tree shaking applies:
// lib/build_config.dart
const debugToolsEnabled = bool.fromEnvironment('DEBUG_TOOLS');void main() {
if (debugToolsEnabled) {
installLogViewer();
}
runApp(const MyApp());
}# QA build: tools on
flutter build apk --flavor staging --dart-define=DEBUG_TOOLS=true
# Store build: the flag is absent, so it is false
flutter build appbundle --flavor productionappFlavor is a constant too, so you can compare it with a flavor name in the same way. It is null when you build without a flavor. The default for a missing flag is "off", which is the safe direction: a build that forgets the flag ships without the tools.
Level 3: the package is still in the app
Levels 1 and 2 remove your Dart code. A debug package is different:
- It is still a dependency, so it is still resolved, downloaded and built.
- If it contains a native plugin, the plugin is registered in the iOS and Android projects, whether or not your Dart code uses it.
- Assets and permissions that the package declares can still end up in the app.
Flutter leaves out plugins that are only in dev_dependencies from release builds, but a package that your app code imports cannot be a dev dependency. The practical answer is to make the dependency itself different in release builds.
The no-op twin
Create a second package with the same name and the same public API, where everything does nothing:
packages/
├── debug_tools/ # real: overlay, inspector, native plugin
└── debug_tools_noop/ # twin: same API, empty bodies, no native code# packages/debug_tools_noop/pubspec.yaml
name: debug_tools # same name as the real package
publish_to: none
environment:
sdk: ^3.9.0
dependencies:
flutter:
sdk: flutter// packages/debug_tools/lib/debug_tools.dart (real)
class DebugTools {
static void install() { /* log viewer, network inspector, ... */ }
}
class DebugOverlay extends StatelessWidget {
const DebugOverlay({required this.child, super.key});
final Widget child;
@override
Widget build(BuildContext context) =>
Banner(message: 'DEBUG', location: BannerLocation.topEnd, child: child);
}// packages/debug_tools_noop/lib/debug_tools.dart (twin)
class DebugTools {
static void install() {}
}
class DebugOverlay extends StatelessWidget {
const DebugOverlay({required this.child, super.key});
final Widget child;
@override
Widget build(BuildContext context) => child;
}Your app code does not change. It calls DebugTools.install() and wraps the app in DebugOverlay in every build, for example with MaterialApp(builder: (context, child) => DebugOverlay(child: child!)). In a store build those calls do nothing.
Swap it at build time with pubspec_overrides.yaml
Dart reads a file named pubspec_overrides.yaml next to pubspec.yaml and applies it on top. Do not commit it. Write it only for the builds that must not contain the tools:
# apps/my_app/pubspec_overrides.yaml
dependency_overrides:
debug_tools:
path: ../../packages/debug_tools_noopWe tested this swap with two small Dart packages. After dart pub get, pub prints the package as overridden, and the app runs the no-op code. When the file is removed and pub get runs again, the real code returns. Run pub get after every change, because the resolved packages are cached in .dart_tool/package_config.json.
Put the decision in a script, so CI and your laptop do the same thing. Make the safe case the default: only the flavors you list as internal keep the tools.
#!/usr/bin/env bash
# tool/prepare_build.sh <flavor>
set -euo pipefail
cd "$(dirname "$0")/../apps/my_app"
case "${1:?usage: prepare_build.sh <flavor>}" in
development|staging)
rm -f pubspec_overrides.yaml
;;
*)
cat > pubspec_overrides.yaml <<'EOF'
dependency_overrides:
debug_tools:
path: ../../packages/debug_tools_noop
EOF
;;
esac
flutter pub getA new flavor that nobody listed gets the empty twin. That is the direction you want when someone forgets.
Add pubspec_overrides.yaml to .gitignore, and delete the file after the build with a final rm -f pubspec_overrides.yaml && flutter pub get. Use if: always() in CI, so the cleanup also runs when the build fails.
If your repo is a pub workspace (see Flutter monorepo with Melos), try this on your SDK version before you rely on it.
Prove it: check the build
A swap that is not checked is a swap you hope worked. Add a check to the release job. Use the build's own files, not your memory of what you changed.
1. The resolved package is the twin. After pub get, .dart_tool/package_config.json lists where each package comes from. It sits next to the app, or at the workspace root in a pub workspace.
if grep -q '"rootUri": "[^"]*/debug_tools"' apps/my_app/.dart_tool/package_config.json; then
echo "debug_tools (the real package) is in the release build" >&2
exit 1
fi2. No debug plugin is registered. Flutter lists the native plugins of the app in .flutter-plugins-dependencies:
if grep -q 'my_debug_plugin' apps/my_app/.flutter-plugins-dependencies; then
echo "debug plugin is registered" >&2
exit 1
fi3. Look at the compiled size by package. The size tool lists the Dart packages in the compiled app:
flutter build apk --analyze-size --target-platform=android-arm64 --flavor productionRun the check after the build and before the cleanup step. Fail the job when it finds a leak.
Common problems
- The debug code is gone but the app is still big. A native plugin is still registered. Swap the package, do not only gate the call.
- QA builds lost their tools. You used
kDebugMode. Use a flag or a swap based on the flavor. - A new flavor shipped with tools. The script listed the store flavors instead of the internal ones. Flip the list.
- Release works, tests fail. Test files that import a debug-only symbol need the twin to export it too. Give the twin every public library the real one has.
- The twin drifts. Someone adds a method to the real package and forgets the twin. The release build fails to compile, which is the right signal. Keep both packages in one pull request.
How Archipelago does this
This is the build_prepare tool, and it runs in every generated Fastlane lane.
- Debug packages come in pairs:
app_debuggerandapp_debugger_noop,app_blackboxandapp_blackbox_noop, and so on. The twin keeps the same class names. Only the import path changes. build_prepare.yamllists the pairs and theinternal_flavors(by defaultdevelopment,staging,alpha,beta). Every other flavor, including one you add later, gets the twins.- Archipelago swaps the dependency in
pubspec.yamland rewrites thepackage:imports, instead of using an override file. A second list,debug_build_only, follows the build mode instead of the flavor (used for AI agent tooling that must never reach any release build). dart run monorepo_toolkit build-prepare --check-leaks --flavor production --mode releaseruns after the build. It reads the app's dependency graph,pubspec.lockand the plugin list, and exits with an error if a debug-only package is there. The generated Fastfile and the CI workflows already call it.--restoreputs the files back exactly, also when the build fails.- The debugger has no runtime flavor check of its own: a flavor in
internal_flavorsshows its entry point, any other flavor has the noop twin.
The build-prepare tool is part of the free monorepo toolkit. The app_debugger brick that uses it is a Pro brick.
References: Build Prepare, Build Prepare for Production, and the app_debugger brick.
FAQ
Is kDebugMode enough?
For your own small debug code in a debug build, yes. It does not cover QA release builds, and it does not remove packages or native plugins.
Does tree shaking remove an unused package?
It removes unused Dart code from the compiled output. It does not remove the dependency, its native plugin or its declared assets. Check the build instead of assuming.
Can I use conditional imports instead?
Conditional imports (if (dart.library.io)) choose by platform, not by debug or release. They do not help here.
Should the real debug package be a dev dependency?
Flutter leaves plugins that are only dev dependencies out of release builds. But if your app code imports the package, it is not just a dev dependency, and the analyzer will warn. Use the twin instead.
What about logging and crash reporting?
Those are usually wanted in production. Keep them, and control what they collect with user consent. Remove inspection tools, not telemetry that you need.
Related guides
Try Archipelago
Make a free account, install the CLI, and generate a project:
dart pub global activate archipelago_cli
archipelago auth login --interactive
archipelago create --output ./my_appThe Free plan includes 3 project scaffolds per month and the Free bricks. See Getting Started for each step, and pricing for Pro and Enterprise.