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.

ASOasis
7 min read
Migrating to Dart 3 Null Safety in Flutter: A Practical Guide

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

  1. Create a migration branch; freeze feature work that touches core models.
  2. Audit dependencies for null‑safety support.
  3. Update SDK constraints for a two‑phase upgrade (Dart 2.x → null safety; then Dart 3).
  4. Run the migration tool and review changes.
  5. Fix edge cases the tool can’t infer.
  6. Remove legacy flags and annotations.
  7. 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 null
  • List<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.x language version comments in files.
  • Remove --no-sound-null-safety from run configs, build scripts, and CI.
  • Drop conditional code paths that assumed unsound mode.

Step 6: Strengthen analysis and tests

  • Run dart analyze until the project is clean.
  • Add unit and widget tests for codepaths you touched, especially JSON parsing and platform integrations.
  • Execute flutter test and at least one full flutter run on 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.0 upper bound during migration; then raise to >=3.0.0 <4.0.0 after 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.
  • late for DI fields: Prefer constructor injection. Use late only when lifecycle enforces initialization order.
  • JSON parsing: Cast via as T? and provide defaults. Avoid dynamic leaks that hide nullability.
  • Collections of nullables: Many APIs expect List<T>, not List<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-safety shows no legacy packages
  • pubspec.yaml uses sdk: ">=3.0.0 <4.0.0"
  • No --no-sound-null-safety flags anywhere
  • dart analyze passes
  • flutter test is 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