Migrating to Dart 3 Null Safety in Flutter: A Practical Guide
Step-by-step guide to migrate a Flutter codebase to Dart 3’s sound null safety with commands, patterns, pitfalls, and checklists.
Image used for representation purposes only.
Overview
Dart 3 makes sound null safety the only allowed mode. For Flutter teams, that’s great news for reliability and performance—but it also means legacy, unsafely-nullable code must be migrated before you can fully adopt modern Flutter 3.x toolchains. This guide walks you through a pragmatic, low‑risk migration path, shows what the tooling does (and doesn’t) fix, and highlights common pitfalls you’ll meet on real projects.
Why sound null safety matters
Null safety makes “non‑nullability” the default. A value of type String can never be null; a String? can. The compiler and analyzer enforce this contract across your entire dependency graph (your code and its packages), enabling:
- Fewer runtime null dereference crashes
- Better tree‑shaking and inlining opportunities, improving app size and speed
- Clearer intent in APIs and data models
With Dart 3, mixed/unsound modes and runtime escape hatches are gone. Your codebase and all dependencies must be fully migrated.
Migration plan at a glance
- Create a migration branch; freeze feature work that touches core models.
- Audit dependencies for null‑safety support.
- Update SDK constraints for a two‑phase upgrade (Dart 2.x → null safety; then Dart 3).
- Run the migration tool and review changes.
- Fix edge cases the tool can’t infer.
- Remove legacy flags and annotations.
- Tighten lints, test, and roll out incrementally.
Prerequisites and workspace setup
- Ensure you can run the Dart command-line tools that include the migration tool. If you’re still on legacy code, perform the null-safety migration while on the latest Dart 2.x/Flutter 3.x that supports it.
- Create a dedicated branch, e.g.,
feat/dart3-null-safety. - Make sure CI runs static analysis (
dart analyze) and tests (flutter test). - Adopt modern lints to catch issues early. In
analysis_options.yaml:
include: package:flutter_lints/flutter.yaml
linter:
rules:
- avoid_print
- prefer_final_locals
Step 1: Audit your dependencies
Use the package tooling to see which dependencies are ready:
dart pub outdated --mode=null-safety
- Update everything that has null‑safe releases:
dart pub upgrade
- Replace or fork packages with no null‑safe versions. Prioritize leaf dependencies first.
Tip: Split your app into packages if it’s a monolith. Migrate leaf packages first, then the app. Publishing internal packages with null safety smooths the path for dependents.
Step 2: Stage the SDK constraints (two‑phase)
You’ll migrate under Dart 2.x, then bump to Dart 3 when fully sound.
Phase A (enable null safety while staying on Dart 2.x):
# pubspec.yaml
environment:
sdk: ">=2.19.0 <3.0.0"
flutter: ">=3.7.0"
Phase B (after successful migration and green tests):
# pubspec.yaml
environment:
sdk: ">=3.0.0 <4.0.0"
Commit after each phase and let CI validate.
Step 3: Run the migration tool
From the package root:
dart migrate
This launches an interactive preview in your browser showing proposed edits and analysis diagnostics. Review file by file. The tool inserts ?, !, late, and default values where it can infer soundness.
When satisfied:
dart migrate --apply-changes
Commit immediately so you can easily revert targeted pieces if needed.
Step 4: Clean up and modernize your code
The tool gets you 80–90% there. The last mile is manual. Below are the patterns you’ll hit most often—and the safest fixes.
4.1 Non‑nullable by default
Change fields that must always exist to non‑nullable and initialize them:
class User {
final String id; // non-nullable
final String? nickname; // optional
User({required this.id, this.nickname});
}
Use required named parameters to encode construction guarantees.
4.2 Avoid overusing late
late defers initialization but throws at runtime if you access before setting.
Bad:
late String token; // set somewhere, maybe
Better: initialize eagerly or use a nullable with a safe access path.
String? _token;
String get token => _token ?? '';
When asynchronous setup is mandatory (e.g., shared preferences), keep late final but centralize initialization order:
late final SharedPreferences prefs;
Future<void> init() async {
prefs = await SharedPreferences.getInstance();
}
4.3 Replace forced ! with checks or defaults
The null‑assertion operator ! should be rare and justified.
Bad:
final len = user!.name.length; // may crash
Better:
final len = user?.name.length ?? 0;
Or assert explicitly at the boundary where you’ve proven non‑null:
User requireUser(User? u) {
assert(u != null, 'User must be set before calling');
return u!; // documented and guarded
}
4.4 Use null‑aware operators
- Safe navigation:
a?.b?.c - Defaulting:
value ?? fallback - Assign-if-null:
x ??= compute() - Null-aware cascade:
object?..method()
final title = json['title'] as String? ?? 'Untitled';
controller?..dispose();
4.5 Nullable functions and callbacks
To call a possibly-null callback, use ?.call():
final VoidCallback? onTap;
...
onTap?.call();
4.6 Collections and generics: mind the ?
There’s a world of difference between:
List<String?>: list is non‑null, elements may be nullList<String>?: list may be null, elements are non‑null when the list exists
Prefer non‑nullable collections with nullable elements only if truly needed.
Filtering nulls:
final raw = <String?>['a', null, 'b'];
final clean = raw.whereType<String>().toList(); // ['a', 'b']
Maps from JSON:
final map = jsonDecode(source) as Map<String, dynamic>;
final age = map['age'] as int?; // may be absent or null
4.7 Futures and streams
Be deliberate with nullability of async results:
Future<User?> fetchUser(); // may return null when not found
When consuming:
final user = await fetchUser();
if (user == null) return;
// user is promoted to User here
4.8 Flutter APIs and platform channels
Many Flutter methods now express nullability precisely. Example with MethodChannel:
static const _platform = MethodChannel('app/device');
final String? version = await _platform.invokeMethod<String>('getVersion');
UI constructors should encode requirements with required and non‑null fields:
class UserTile extends StatelessWidget {
const UserTile({required this.name, super.key});
final String name;
@override
Widget build(BuildContext context) => Text(name);
}
4.9 Equality and nulls
== can be invoked on nullables thanks to null‑aware dispatch:
if (a == b) { ... } // safe even if a is null
But extension methods on nullable receivers require ?:
extension Trim on String { String trimmed() => trim(); }
String? maybe;
maybe?.trimmed();
Step 5: Remove legacy flags and annotations
Dart 3 removes unsound execution. Clean these up:
- Delete any
// @dart=2.xlanguage version comments in files. - Remove
--no-sound-null-safetyfrom run configs, build scripts, and CI. - Drop conditional code paths that assumed unsound mode.
Step 6: Strengthen analysis and tests
- Run
dart analyzeuntil the project is clean. - Add unit and widget tests for codepaths you touched, especially JSON parsing and platform integrations.
- Execute
flutter testand at least one fullflutter runon each target platform. - Consider enabling additional lints:
unnecessary_late,unnecessary_nullable_for_final_variable_declarations,avoid_redundant_argument_values.
Step 7: Performance and size gains
With sound null safety, the compiler can:
- Skip null checks on non‑nullable locals
- Inline aggressively and eliminate dead branches
- Improve tree shaking of unused generics and nullable variants
Measure with flutter build apk --analyze-size or platform‑specific size reports to validate wins.
Step 8: Rollout strategy for large repos
- Migrate packages first, app last. Publish internal packages with a
<3.0.0upper bound during migration; then raise to>=3.0.0 <4.0.0after verification. - Land small, reviewable PRs (e.g., “models,” “network,” “widgets”).
- Gate merges with CI that runs analysis and tests on every PR.
- Coordinate with product to schedule a stabilization window after the final switch to Dart 3.
Common pitfalls and robust fixes
- Overusing
!: Replace with checks, defaults, or refactoring to non‑nullable invariants. latefor DI fields: Prefer constructor injection. Uselateonly when lifecycle enforces initialization order.- JSON parsing: Cast via
as T?and provide defaults. Avoiddynamicleaks that hide nullability. - Collections of nullables: Many APIs expect
List<T>, notList<T?>. Normalize early. - Tests silently passing: Add tests that assert thrown exceptions when null contracts are violated.
- FFI/platform channels: Carefully annotate pointers and channel results as nullable where native code can return “no value.”
Final checklist
-
dart pub outdated --mode=null-safetyshows no legacy packages -
pubspec.yamlusessdk: ">=3.0.0 <4.0.0" - No
--no-sound-null-safetyflags anywhere -
dart analyzepasses -
flutter testis green and critical flows verified on devices - Release notes mention the migration and any public API changes
Conclusion
Migrating to Dart 3’s sound null safety is a one‑time effort that pays permanent dividends: clearer APIs, fewer crashes, and better performance. Treat it as a structured refactor—migrate dependencies first, let the tool do the bulk edits, then tighten the last mile by hand. With a disciplined checklist and CI, most teams can move a medium‑sized Flutter app in days, not weeks, and unlock the full capabilities of the modern Flutter toolchain.
Related Posts
Mastering Flutter Platform Channels on iOS and Android: A Practical Guide
A practical, end-to-end guide to Flutter platform channels on iOS and Android with Kotlin, Swift, Dart code, testing, performance, and pitfalls.
X axes the in‑app Dark Mode toggle and kills “Dim”: what changed and what you can do now
X removes its in‑app Dark Mode toggle and kills “Dim,” forcing theme control to OS settings — here’s what changed, why it matters, and your options.
Android vs iOS in 2025: A Comprehensive Comparison
An in-depth look at Android and iOS in 2025, highlighting the latest features, user experience, privacy, and ecosystem strengths.