Migrating an Existing Dart Project to Sound Null Safety: A Practical Guide
Sound null safety eliminates silent null dereferences in Dart. This guide walks you through preparing, running the migration tool, handling third‑party packages, a concrete code example, and trade‑offs so you can modernise your Flutter or server‑side Dart code safely.
09 Jan 2026, 23:50 UTC

Problem: Legacy Dart Code Still Allows Null Dereferences
Many Flutter and server‑side Dart projects began before 2021, when the language didn’t enforce null safety. In those codebases, a variable that can be null is treated the same as a non‑nullable one. A typo or an unexpected API response can silently return null, and the app crashes only when code later tries to call a method on that null value. This hidden risk is hard to catch in tests and leads to a brittle runtime.
Takeaway: Sound Null Safety Gives You Early, Static Guarantees and Safer Runtime
Enabling sound null safety turns the language into a safety net: every type is non‑nullable by default, and the analyzer will flag any place where a null could slip through. When you run your app in debug mode, an assertion will fire immediately if a null dereference is attempted. The result is fewer crashes, clearer intent, and better tooling (e.g., autocompletion and refactoring).
Why Null Safety Matters
- Early error detection: The analyzer stops you before you even run the code.
- Explicit intent: Code that declares
String?clearly says “this may be null.” - Performance: The compiler can generate more efficient code when it knows a value can’t be null.
- Community support: Most new packages are null‑safe, and the Dart SDK ships with sound null safety by default.
Preparing the Codebase
Before you run the migration tool, make sure your environment is ready:
Upgrade the SDK: Run
dart --versionto see you’re on 2.12 or newer. If not, install the latest stable SDK.Update dependencies: Many packages have released null‑safe versions. Use
dart pub outdated --mode=null-safetyto list those that need upgrading. Then rundart pub upgrade --null-safetyto pull them in. If a package hasn’t yet been migrated, you have two options: keep the legacy version (with--no-sound-null-safety), or fork it.Set the migration flag: In your
pubspec.yaml, addenvironment: sdk: "^2.12.0". This tells the analyzer that you intend to use null safety.Run a clean build:
dart cleanand thendart pub getto ensure a fresh state.
Running the Migration Tool
The dart migrate command is the workhorse. It performs a static analysis, suggests changes, and produces a migration plan you can review before applying.
# Run the migration analysis
dart migrate --apply-changes --no-fix-issues
Key points:
- --apply-changes writes the suggested edits to the files.
- --no-fix-issues keeps the tool from automatically fixing any remaining issues; you’ll review them manually.
- After the run, the tool generates a
migrate_analysis_report.jsonyou can open in an editor to see which files were touched.
Once you’re satisfied, run dart analyze again. If any null‑safety warnings remain, address them. The analyzer will point to the exact line and provide a fix suggestion.
Dealing with Third‑Party Packages
Even if your own code is null‑safe, a dependency that isn’t can block the migration. The analyzer will mark the entire file as legacy if it imports a non‑null‑safe package. Two solutions:
Use legacy mode for that library: Wrap the import with
import 'package:foo/foo.dart' if (true) else 'package:foo/foo_legacy.dart';and run the app with--no-sound-null-safety. This keeps the dependency in legacy mode while the rest of your code is null‑safe.Fork and migrate the package: If the package is small, copy it into your repo, run
dart migrateon it, and use the forked version. This is more work but gives you full control.
Example: Convert a Simple Service Class
Let’s walk through a concrete example. Suppose you have a service that fetches user data from an API. The original code:
class UserService {
Future<String> fetchUsername(int userId) async {
final response = await http.get('https://api.example.com/users/$userId');
if (response.statusCode == 200) {
return jsonDecode(response.body)['username'];
} else {
return null; // Problematic: returning null where a String is expected
}
}
}
After running dart migrate, the tool rewrites the method signature and adds a late field to handle the nullable return:
class UserService {
Future<String> fetchUsername(int userId) async {
final response = await http.get(Uri.parse('https://api.example.com/users/$userId'));
if (response.statusCode == 200) {
return jsonDecode(response.body)['username'] as String;
} else {
throw Exception('Failed to load username');
}
}
}
Key changes:
- The return type is now a non‑nullable
String. - Instead of returning
null, the code throws an exception, making the contract explicit. - The
Uri.parsecall replaces the raw string URL for type safety.
After the migration, running dart analyze shows no null‑safety warnings for this file.
Trade‑offs & Limitations
- Legacy code can still break: If you have
dynamiccasts or rely on runtime type checks, null safety won’t catch those. Useassertorif (value == null)guards as needed. - Large codebases produce many warnings: The migration tool can generate thousands of changes. Prioritise the most critical modules first.
- Dependency blockers: A single un‑migrated package can prevent the entire project from running in sound mode. Plan for forks or wait for the upstream release.
- Runtime overhead: In debug mode, assertions add a small performance hit. In release builds, they’re removed.
Actionable Checklist
- Upgrade SDK to 2.12+ and set
environment.sdk. - Update all dependencies to null‑safe versions.
- Run
dart migrateand review the suggested changes. - Fix any remaining analyzer warnings manually.
- Run
dart analyzeanddart testto confirm no issues. - Deploy to a staging environment with
--enable-assertsto catch any runtime null dereferences. - Monitor production logs for unexpected null errors; adjust error handling if needed.
By following this roadmap, you can progressively bring an existing Dart codebase into the safer, more maintainable world of sound null safety without breaking functionality.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.