LEARN · DEBUGGING GUIDE

Flutter Null Safety Migration Error: The Non-Obvious Breakage Patterns

The migration from Dart 2.x sound null safety to 3.x isn't just about adding '?'. Transitive dependencies, mixed-version packages, and implicit casts cause the nastiest breakages. Here's how to diagnose and fix them.

IntermediateMobile9 min read

What this usually means

Flutter's null safety migration is a multi-step process that often fails not because of your own code, but because of the interplay between migrated and unmigrated packages. The Dart compiler enforces type safety at compile time, but when packages use `@dart=2.9` opt-out or incomplete migration, the runtime type system can still throw null errors. The most common hidden cause is a transitive dependency that hasn't been migrated, or a conditional import/exports that the analyzer doesn't catch. Another frequent issue is the mismatch between `pubspec.yaml` SDK constraints and the actual Dart version used by Flutter's SDK.

( 01 )Fast diagnosis

The first ten minutes — establish facts before touching code.

  • 1Run `dart pub deps --style=tree` to see all transitive dependencies and check which ones have `@dart=2.9` in their source files
  • 2Run `flutter pub outdated --mode=null-safety` to list which direct and transitive dependencies are not yet migrated
  • 3Run `dart analyze` with `--fatal-infos` to detect implicit casts from nullable to non-nullable types (these are warnings that become errors at runtime)
  • 4Check if any package uses conditional import with `package:meta/meta.dart` `@optionalTypeArgs` that can bypass null safety checks
  • 5Enable `--no-sound-null-safety` temporarily with `flutter run --no-sound-null-safety` to isolate if the error is soundness-related
  • 6Look for `late final` variables that are never assigned in `initState` or constructor – they compile fine but throw at runtime
( 02 )Where to look

The specific files, logs, configs, and dashboards that usually own this bug.

  • search`pubspec.yaml` – verify SDK constraint `sdk: ">=2.12.0 <3.0.0"` and check `dependency_overrides`
  • search`lib/` – search for `// @dart=2.9` or `// @dart=2.12` comments in source files (legacy opt-out markers)
  • search`build/` – after a failed run, check `build/app/outputs/flutter-apk/app-debug.apk` stack trace in logcat
  • search`dart_tool/package_config.json` – see which packages are resolved to null-safe versions
  • search`pubspec.lock` – look for packages with `version:` not migrated (e.g., `shared_preferences: "0.5.12+3"` is pre-migration)
  • search`android/app/build.gradle` – ensure `minSdkVersion` is 21+; older versions can cause `NullPointerException` in platform channels
  • search`ios/Runner.xcodeproj` – check for Swift/Obj-C interop nullability annotations in method channel implementations
( 03 )Common root causes

Practical causes, not theory. These are the things you will actually find.

  • warningTransitive dependency not migrated: a deep dependency like `firebase_core` depends on an older `plugin_platform_interface` that hasn't been updated
  • warning`@dart=2.9` file left over in a partially migrated package – the analyzer skips it but runtime doesn't
  • warning`late` variables used with `!` operator in places where the value can legitimately be null (e.g., after `dispose`)
  • warningMethod channel handlers that return `null` from `onMethodCall` but are typed as non-nullable in Dart
  • warning`InheritedWidget` migration done incorrectly: `updateShouldNotify` returns `true` but the new `of` method returns nullable, causing `!` to crash
  • warning`dart:convert` `jsonDecode` returns `dynamic?` – implicit cast to `Map<String, dynamic>` fails without `as`
  • warning`FutureBuilder` snapshot `data` is `T?` – accessing `snapshot.data!` before connection state is `done` throws
( 04 )Fix patterns

Concrete fix directions. Pick the one that matches your root cause.

  • buildAdd `dependency_overrides` in `pubspec.yaml` to force migrate transitive dependencies: `dependency_overrides: { plugin_platform_interface: ^2.0.0 }`
  • buildUse `// @dart=2.12` on files that are fully migrated to avoid accidental partial migration warnings
  • buildReplace `late final` with `late` + null check or use `late final` with guarantee of assignment before first read (e.g., in constructor)
  • buildFor method channels, wrap the handler response in `Future.value(result ?? default)` to avoid null propagation
  • buildIn `InheritedWidget`, change `of` method to return nullable and handle `null` at call sites instead of using `!`
  • buildRun `dart migrate --apply-changes` on the whole project, then manually review diffs for implicit casts
  • buildUse `dart pub upgrade --null-safety` to upgrade all compatible packages to null-safe versions in one go
( 05 )How to verify

A fix you cannot prove is a guess. Close the loop.

  • verifiedRun `flutter analyze` with `--no-fatal-infos` and ensure zero errors; then run with `--fatal-infos` and fix all warnings
  • verifiedCreate a simple widget test that instantiates the main app and checks for any `NullError` in the console
  • verifiedRun `flutter run --release` on a physical device and monitor `adb logcat` for any `NullPointerException` or `NullError`
  • verifiedCheck that all `pubspec.lock` entries have `version:` with at least `2.0.0` for packages that were pre-null-safety
  • verifiedUse `dart run --enable-asserts` to catch late initialization errors during development
  • verifiedFor platform channels, write a unit test that calls each method and verifies non-null return values
( 06 )Mistakes to avoid

Things that make this bug worse or harder to find.

  • warningAdding `// @dart=2.9` to opt-out of null safety globally – this only masks the problem and leads to runtime crashes
  • warningUsing `!` operator blindly without checking for `null` – prefer `??` or conditional access
  • warningAssuming that if `flutter analyze` passes, the app is safe – runtime behavior can differ for platform channels and dynamic code
  • warningMigrating all files at once without incremental compilation – leads to massive type errors that are hard to debug
  • warningIgnoring `deprecated` warnings from packages – they often indicate that a null-safe version exists
  • warningForgetting to update `minSdkVersion` in Android – older SDKs can cause null-related platform crashes
( 07 )War story

The Case of the Silent Null Error in Production

Senior Flutter DeveloperFlutter 2.5.3 (Dart 2.14), Firebase 8.0.0, shared_preferences 2.0.6, Android 11, iOS 15

Timeline

  1. 09:00Ran `flutter pub upgrade --null-safety` and migrated all direct dependencies
  2. 09:30`flutter analyze` passed with zero errors
  3. 10:00Deployed to TestFlight – no crash on iOS simulator
  4. 10:45User reports crash on Android 10 after saving preferences
  5. 11:00`adb logcat` shows `NullError: LateInitializationError: Field '_userName' has not been initialized.`
  6. 11:30Found `late final String _userName;` in a State class – but `initState` called `loadPreferences()` that sets it asynchronously
  7. 12:00Realized `loadPreferences()` used `shared_preferences` which returns `null` for missing keys – but code assumed non-null
  8. 12:30Changed to `String? _userName;` and handled null everywhere – crash gone

We had just completed the null safety migration for our Flutter app. The process was smooth – we ran `dart migrate --apply-changes`, upgraded all packages, and the analyzer was green. We deployed to TestFlight and everything looked fine on iOS. But within hours, Android users started crashing after saving preferences. The crash was a `NullError` with a `LateInitializationError` message. I couldn't reproduce it on my device at first because it only happened when a certain preference was not yet set.

I dug into the stack trace and found the culprit: a `late final String _userName;` field in a State class. In `initState`, we called `_loadPreferences()` which awaited `SharedPreferences.getInstance()` and then read a string value. But if the preference didn't exist, `getString` returns `null`. The `late final` field was never assigned because the assignment line was inside a condition that only ran if the key existed. The next time the widget rebuilt, accessing `_userName` threw the `LateInitializationError`.

The fix was straightforward: change the field to `String? _userName` and use null-aware operators everywhere. After the fix, we also audited all `late final` fields in the codebase and replaced them with nullable variables or ensured they were always assigned in the constructor. The lesson: the null safety migration is not just about adding `?` – it's about understanding the runtime flow and that `late final` is a ticking time bomb if the assignment path is conditional.

Root cause

`late final String _userName;` assigned conditionally in an async method that could complete without setting the field, leading to `LateInitializationError` at runtime.

The fix

Changed `late final String _userName` to `String? _userName` and updated all reads to use `_userName ?? 'default'` or handle null explicitly.

The lesson

`late final` must be assigned exactly once before first read – any conditional async assignment is dangerous. Prefer nullable types or initialize in constructor.

( 08 )Transitive Dependency Hell: The Hidden Migration Blocker

When you run `flutter pub upgrade --null-safety`, it only upgrades your direct dependencies. Transitive dependencies (dependencies of your dependencies) may remain unmigrated, especially if they are pinned by version constraints. The Dart analyzer will not flag these because it only checks the direct API surface. However, at runtime, if your code passes a non-nullable value to an unmigrated function that expects nullable, the compiler may inject null checks that fail.

The fix is to inspect the entire dependency tree with `dart pub deps --style=tree`. Look for packages with versions that predate null safety (e.g., `path_provider: ^1.6.0` is pre-null-safety). Then either upgrade the parent package to a version that depends on a migrated version, or use `dependency_overrides` to force a specific version. Be careful: overriding transitive dependencies can break the parent package if the API changed.

( 09 )The `@dart=2.9` Time Bomb

Some packages use `// @dart=2.9` at the top of their source files to opt out of null safety. This is a per-file opt-out. If a package is partially migrated – some files have the comment, others don't – the analyzer may treat the package as opted-in, but at runtime, the opted-out files can still produce null values. This leads to a mismatch: the compiler thinks a variable is non-nullable, but the runtime returns null.

To detect this, search your entire project and all package sources for `// @dart=2.9`. You can do this with `grep -r "// @dart=2.9" .` in your project root. If you find any, check if the package has a newer version that removes the comment. If not, you may need to file an issue or fork the package. In the meantime, you can treat all values from that package as potentially null.

( 10 )Platform Channel Null Safety: The Obscure Crash

Flutter's platform channels (MethodChannel, EventChannel) are a common source of null safety errors because they bridge Dart and native code. The Dart side may declare a non-nullable return type, but the native side can return null or not implement the handler, returning null by default. This is especially common when migrating plugins: the Dart side gets null safety, but the native side (Java/Swift) may still return null for unimplemented methods.

The fix is to always handle null returns from platform channels. Wrap channel calls in a try-catch and use `?.` or `??` operators. Additionally, ensure that your Android `MethodCallHandler` returns a non-null result for every method. Use `result.success(defaultValue)` instead of just `result.success(null)`.

( 11 )Incremental Migration Pitfalls with `flutter migrate`

The `dart migrate` tool is great for automatic migration, but it has a flaw: it can introduce implicit casts that the analyzer considers warnings but the runtime treats as errors. For example, if a function returns `String?` and the migration tool adds a `!` operator without checking the call site, it will compile but crash at runtime if the value is null.

After running `dart migrate --apply-changes`, always do a manual review of the diff. Pay special attention to places where `!` was inserted. Also, run `flutter analyze --fatal-infos` to catch any warnings that should be errors. A better approach is to run `dart migrate` interactively and accept/reject changes per suggestion.

( 12 )The `FutureBuilder` Null Trap

A common pattern is using `FutureBuilder` with a `snapshot.data!` to access the data. In null safety, `snapshot.data` is `T?`. If the future hasn't completed, `snapshot.data` is `null`, and the `!` operator throws a `NullError`. This often happens when the build method calls `setState` during the future's execution, causing a rebuild before the future completes.

Always check `snapshot.connectionState` before accessing `snapshot.data`. Use a switch or if-else to handle `waiting`, `done`, and `error` states. For example: `if (snapshot.connectionState == ConnectionState.done && snapshot.hasData) { return Text(snapshot.data!); } else { return CircularProgressIndicator(); }`.

Frequently asked questions

Why does `flutter analyze` pass but my app crashes with null errors at runtime?

The Dart analyzer can't catch all runtime null safety violations. Common reasons include: (1) platform channel handlers returning null from native code, (2) `late` variables that are not assigned before first read, (3) unmigrated transitive dependencies that produce null values, and (4) dynamic code via `dart:mirrors` or `dart:ffi`. To catch these, enable runtime assertions with `--enable-asserts` and test on actual devices.

How do I force a transitive dependency to use a null-safe version?

Add a `dependency_overrides` section in your `pubspec.yaml`. For example, if `firebase_core` depends on `plugin_platform_interface` which is not migrated, you can force a null-safe version: `dependency_overrides: { plugin_platform_interface: ^2.0.0 }`. After that, run `flutter pub get` and test thoroughly, as overrides can break the parent package.

What does `// @dart=2.9` mean and why is it a problem?

This comment at the top of a Dart file opts that file out of null safety, meaning it follows Dart 2.9 (pre-null-safety) rules. If a package has a mix of files with and without this comment, the package is partially migrated. The analyzer may consider it opted-in, but the opted-out files can still produce null values, causing runtime errors. You should upgrade the package to a fully null-safe version or replace it.

Should I use `late final` or nullable fields for variables initialized asynchronously?

Avoid `late final` for async initialization unless you are certain the assignment will always happen before the first read and only once. If the initialization depends on a future or a conditional path, use a nullable field (`T?`) and handle null at each read. Alternatively, use `late` without `final` and assign in an async method, but ensure you check if it's initialized before reading.

How do I migrate a Flutter plugin to null safety?

For a plugin, you need to migrate both the Dart and native sides. For Dart: update `pubspec.yaml` SDK constraint, run `dart migrate`, and fix any issues. For native: in Android, use Kotlin with `?` for nullable types; in iOS, use Swift optionals and annotate `nullability` in ObjC headers. Ensure method channel handlers return non-null values or handle null on the Dart side.