Migrating Dart Projects to Sound Null Safety: A Practical Guide for Dart 2 and Dart 3
Learn how to migrate a Dart project to sound null safety, with version‑specific steps, a worked example, trade‑offs, and a verification checklist.
28 Apr 2026, 19:30 UTC

The problem: unexpected null dereferences
Before Dart 2.12 every reference could hold null and the type system could not warn you when a value might be missing. The result is a class of bugs that surface only at runtime, often as a NoSuchMethodError when you try to call a method on null.
Why null safety matters
Sound null safety makes non‑nullable the default. A variable declared as String can never hold null; if you need the possibility of absence you write String?. The compiler enforces the contract at compile time, eliminating a whole category of null‑dereference crashes. Benefits include clearer APIs, better IDE autocomplete, and safer refactors because changing a field from nullable to non‑nullable is checked by the compiler.
Migration steps for projects that target Dart < 3
- Set the SDK constraint. In
pubspec.yamlensure the environment includes a version that enables null safety, for example:
environment:
sdk: '>=2.12.0 <4.0.0'
For Dart 3 and later the constraint can be ">=3.0.0 <4.0.0"; null safety is enabled by default and there is no opt‑out.
- Run the analyzer. From the project root execute:
dart analyze
The output lists locations where the compiler cannot prove a value is non‑null. Each line is a migration candidate.
- Launch the migration tool. Run:
dart migrate
The tool opens a web UI that proposes concrete edits (adding ?, inserting late, or adding an explicit ! assertion). You can approve changes file by file or accept the whole plan. The tool creates a branch‑like diff that you can apply incrementally.
- Verify with the test suite. After applying the migration, run:
dart test --sound-null-safety
All tests should pass unchanged; any new failures indicate a missed nullability contract, often a LateInitializationError when a late field is accessed before initialization.
Worked example: a simple data model
Consider a User class before migration:
class User {
String name;
String email;
User(this.name, this.email);
}
Both fields are implicitly nullable in pre‑null‑safety Dart. After running dart migrate and reviewing the suggestions, the tool may produce:
class User {
String name;
String? email; // email may be missing in the existing codebase
User(this.name, this.email);
}
The migration inferred that email can be null because at least one call site passes null or leaves the field uninitialized. The compiler now guarantees that name is never null. Any attempt to pass null for name fails to compile.
If you need a non‑nullable field that is initialized later, use late:
class Profile {
late String avatarUrl; // assigned later, but never null after init
void loadAvatar() => avatarUrl = fetchUrl();
}
Accessing avatarUrl before loadAvatar() throws a LateInitializationError, a deterministic failure that is easier to trace than a silent null propagation.
Trade‑offs and limitations
- Third‑party packages. Libraries that have not been migrated can still be used, but their APIs appear as
dynamicorObject?. You must add explicit checks or casts, which re‑introduces some runtime risk. - Late initialization. Over‑using
latecan hide design problems; the error surfaces only when the field is accessed, which may be far from the initialization site. - Build‑time impact. The analyzer performs additional nullability checks, so
dart analyzeand IDE analysis can take a little longer on very large projects. The increase is usually modest and is outweighed by the safety gain.
Actionable next steps
- Open a terminal in your project root and run
dart analyze. Note the count of null‑safety warnings. - Execute
dart migrateand step through the generated plan, approving changes that look correct. - Run the full test suite with
dart test --sound-null-safety. If any test fails, inspect the stack trace for aLateInitializationErroror a failed null check – those are the places where the migration exposed a hidden assumption. - Commit the migrated code and add a CI step that runs
dart analyze --fatal-warningson every pull request to prevent regressions. - If you need to revert, use your version‑control system (e.g.,
git reset --hard HEAD~1) to return to the pre‑migration state.
Following this workflow moves your codebase from “hope it’s not null” to “the compiler guarantees it”, reducing production crashes and making future maintenance far easier.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.