Migrate a Flutter 2 app to Flutter 3.x: the null safety checklist
A version-pinned guide to moving a legacy Flutter 2.x app to a current Flutter 3.x release, with the sound null safety migration as the hard gate: why you need a two-hop path through Dart 2.19, what dart migrate does, and what breaks along the way.
If you still have a Flutter 2.x app that opted out of sound null safety, you cannot jump straight to a current Flutter 3.x release. Dart 3, which shipped in Flutter 3.10 (May 2023), removed unsound null safety entirely and deleted the dart migrate tool, so the upgrade is a two-hop migration: first get the codebase null-safe on Dart 2.19 (Flutter 3.7.x), then bump to the latest Flutter 3.x. Budget a day for a small app and three to five days for a large one with many transitive dependencies. The null safety pass is the gate; everything else (Gradle, iOS minimums, removed widgets) is mechanical once the analyzer is green. This guide pins Flutter 2.10 / Dart 2.16 as the starting point and a current Flutter 3.x line running Dart 3 as the target.
Why this is a two-step trip, not one
The instinct is to download the newest stable Flutter, run flutter pub get, and fix whatever breaks. With an unsound Flutter 2 app that fails immediately, because the tool you need no longer exists in the SDK you are upgrading to.
Sound null safety landed as opt-in in Dart 2.12 (Flutter 2.0, March 2021). For two years a mixed mode let null-safe and legacy libraries coexist. Dart 3 ended that. From the official guide: “In Dart 3, null safety is built in; you cannot turn it off.” A library that never migrated produces, at resolve time:
Because pkg1 doesn't support null safety, version solving failed.
The lower bound of "sdk: '>=2.9.0 <3.0.0'" must be 2.12.0 or higher to enable null safety.
and at runtime, on the old engine, Library doesn't support null safety. The dart migrate interactive tool that fixes this was removed in Dart 3. Dart 2.19.6 is the final SDK that ships it. So the only supported route is: migrate to null safety while you are still on a Dart 2.x SDK, then upgrade the SDK.
Why migrate at all in 2026
- Every current package on pub.dev requires a Dart 3 SDK constraint (
sdk: '>=3.0.0 <4.0.0'). Staying on Flutter 2 freezes you out of new versions ofhttp,provider,go_router, and every Firebase plugin, including their security patches. - Dart 3 unlocked records, patterns, sealed classes, and class modifiers. None of these compile on a Flutter 2 toolchain.
- Sound null safety is a real correctness win: the compiler proves a
Stringis nevernull, so a whole class ofNoSuchMethodError: The method '...' was called on nullcrashes becomes impossible to ship. If you have ever chased that one in production, this is the fix at the type-system level. - Apple and Google store requirements (minimum SDK levels, 64-bit, current build tooling) have moved well past what a Flutter 2 build can satisfy.
What breaks
Severity is how likely the change is to break a typical app, not how hard the fix is.
| Area | Change | Severity |
|---|---|---|
| Null safety | Unsound mode removed in Dart 3; every line of your code and every dependency must be null-safe | high |
dart migrate tool | Removed in Dart 3; only exists up to Dart 2.19.6 | high |
| SDK constraint | pubspec.yaml lower bound must be 2.12.0, then 3.0.0 | high |
| Dependencies | Any package without a null-safe release blocks the whole resolve | high |
| Removed deprecated widgets | ThemeData.accentColor, textSelectionColor, toggleableActiveColor removed in the 3.0 deprecation sweep | high |
| Android toolchain | Gradle, Android Gradle Plugin, and Kotlin floors all rose; old build.gradle fails | high |
| iOS minimum | Minimum deployment target raised to iOS 12; 32-bit armv7 dropped | medium |
| Plugin embedding | Apps still on the v1 Android embedding must move to v2 | medium |
Pre-flight checklist
Do all of this before touching code:
- Commit a clean baseline and tag it:
git tag pre-flutter3-migration. You will roll back to this more than once. - Record your exact current versions:
flutter --versionanddart --version. Pin them somewhere so the migration is reproducible. - Make sure CI builds the app green on the old SDK first. Migrating on top of an already-broken build wastes hours.
- Install a Dart 2.19 / Flutter 3.7.12 toolchain alongside your current one. Use
fvm(Flutter Version Management) so you can switch per project:fvm install 3.7.12. - Inventory dependencies:
flutter pub deps --no-devand note anything unmaintained. A single abandoned package with no null-safe release can stall the entire migration.
Migration steps
-
Pin to Flutter 3.7.12 (Dart 2.19) for the migration. This is the last toolchain that has
dart migrate. Withfvm, runfvm use 3.7.12in the project, thenfvm flutter --versionto confirmDart 2.19.x. Verify:fvm flutter pub getresolves without an SDK error. -
Check dependency readiness before migrating your own code. Run
dart pub outdated --mode=null-safety. It prints a table showing which packages already have null-safe versions and which do not. Verify: every direct dependency shows a resolvable null-safe version in the “Upgradable” or “Resolvable” column. If one does not, find a replacement or fork it now, before going further. -
Upgrade dependencies to their null-safe versions, bottom-up. Dependencies migrate in order: if C depends on B depends on A, then A must be null-safe first. The tooling handles the ordering when you run
dart pub upgrade --null-safetyfollowed bydart pub get. Verify:pubspec.locknow lists null-safe versions anddart pub getexits 0. -
Run the interactive migration tool on your code. From the project root, run
dart migrate. It analyzes the whole package, proposes nullability for every type, and serves a local web UI where you review each inferred?,late, andrequired. Where it guesses wrong, nudge it with hint markers in your source:/*?*/forces nullable,/*!*/forces non-nullable,/*late*/marks late initialization,/*required*/marks a required parameter. Verify: the tool reports zero remaining analysis errors in its summary before you apply. -
Apply the migration and bump the SDK constraint. Click “Apply migration”. The tool rewrites your
.dartfiles, strips the hint comments, and sets the lower bound inpubspec.yaml:# pubspec.yaml -- after the null safety migration, still on Dart 2.19 environment: sdk: '>=2.12.0 <3.0.0'Verify:
dart analyzereports no errors andflutter testpasses on Flutter 3.7.12. Commit this as a standalone checkpoint — you now have a sound, null-safe app that still runs on the old toolchain. -
Switch to the current Flutter 3.x toolchain. Now do the second hop. Run
fvm install stableandfvm use stable(or your pinned target like3.35.x). Raise the SDK constraint to Dart 3:# pubspec.yaml -- targeting the current Flutter 3.x / Dart 3 line environment: sdk: '>=3.0.0 <4.0.0' flutter: '>=3.10.0'Run
flutter pub get. Verify: resolution succeeds. If it fails with “version solving failed”, a dependency still lacks a Dart 3 constraint — rundart pub outdatedto find it. This same error shows up for plain pubspec mistakes too; see the version solving failed fix for the full decision tree. -
Clear removed-API and deprecation errors with
dart fix. Flutter 3.0 deleted APIs that were deprecated in the 2.x line. Rundart fix --dry-runto preview, thendart fix --applyto auto-rewrite the mechanical ones. The common casualties are theme properties:ThemeData.accentColoris gone, which is exactly the accentColor compile error that trips up nearly every 2-to-3 upgrade. Verify:flutter analyzeis clean. -
Fix the Android build tooling. A Flutter 2
android/folder almost always has Gradle, AGP, and Kotlin versions too old for the new embedding. Updateandroid/gradle/wrapper/gradle-wrapper.properties,android/build.gradle, andandroid/app/build.gradleto versions the current Flutter expects (theflutter createtemplate for a throwaway app is the reference). Verify:flutter build apk --debugsucceeds. If you hit anAndroidX conflict, the AndroidX conflict fix walks the resolution.
Verification
Run this smoke test after step 7, on both platforms:
flutter analyzereports zero issues.flutter testpasses with the same count as your pre-migration baseline. A drop means a test file silently failed to compile.flutter build apk --releaseandflutter build ios --release --no-codesignboth succeed.- Launch on a real device, not just the simulator, and exercise the screens that previously crashed with null errors. Sound null safety should have removed the failure modes outright.
- Diff app startup time and frame timings against the tagged baseline. Dart 3’s AOT compiler is generally faster, but check rather than assume.
Rollback plan
The null safety migration is effectively one-way at the code level: once types carry ? and late, reverting by hand is impractical. That is why step 4 is a standalone commit. If the Dart 3 hop (steps 5 to 7) goes wrong, you do not unwind the null safety work — you git reset --hard back to the step-4 checkpoint, which is a fully working null-safe app on Flutter 3.7.12, and retry the toolchain bump. Keep the pre-flutter3-migration tag until the new build has been in production for a release cycle.
Gotchas we hit
An abandoned dependency with no null-safe release. This is the single most common blocker. dart pub outdated --mode=null-safety flags it, but the fix is human: replace the package, fork and migrate it yourself, or vendor it. Decide this in the pre-flight, because discovering it mid-migration means backtracking.
late used as a crutch. The migration tool will happily mark a field late to avoid forcing you to provide an initializer. Every late is a deferred LateInitializationError waiting for a code path that reads before write. Audit each one the tool inserts and prefer a real nullable type or constructor initialization where the lifecycle is not airtight.
dynamic hides nulls from the analyzer. Null safety only protects statically typed code. Fields and JSON maps typed as dynamic still let null flow through and crash at the use site. Migrating is the right moment to give your fromJson factories real types; a mistyped field there throws FormatException or a null crash that the type system cannot catch. If you parse a lot of JSON, tighten those models while you are in here.
Plugin v1 embedding. Apps that predate the v2 Android embedding fail to build on current Flutter with a MainActivity or FlutterApplication error. Regenerate android/app/src/main/.../MainActivity.kt from the current template and delete the old GeneratedPluginRegistrant references.
Skipping the intermediate toolchain. It is tempting to install current Flutter and try to power through. Without dart migrate, you are hand-annotating thousands of types from analyzer errors, which is exactly the work the tool automates. Take the two hops.
Related
- Fix: version solving failed in pubspec.yaml
- Flutter: the getter accentColor isn’t defined for the class ThemeData
- Fix: AndroidX conflict during a Flutter Android build
- How to migrate a Flutter app from GetX to Riverpod
- Provider vs Riverpod vs Bloc for Flutter state management in 2026
Sources
- Dart 3 migration guide — null safety mandatory in Dart 3,
dart migrateremoved, use Dart 2.19 to migrate first. - Migrating to null safety —
dart pub outdated --mode=null-safety,dart migrate, hint markers, bottom-up dependency order, Dart 2.19.6 as the final tool-bearing SDK. - Flutter breaking changes and migration guides — the per-release list of removed and changed APIs.
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.