Upgrading Flutter Projects Without Pain: My Step-by-Step Routine
How I approach upgrading Flutter projects: SDK, packages, Gradle, AGP, Kotlin, CocoaPods and Xcode, one change per commit, with CI catching what I miss.

The worst Flutter upgrade is the one you put off for eighteen months. By then the SDK is several releases behind, half your packages have new major versions, Gradle wants a newer Java, Xcode has moved on, and Google Play is politely reminding you about the target SDK deadline. You run flutter upgrade, hit build, and get forty errors that have nothing to do with each other.
I’ve done that kind of catch-up upgrade more times than I’d like, mostly on apps I inherited. The work itself is rarely hard. What makes it painful is doing everything at once. So my whole approach comes down to one idea: change one layer at a time, and prove each layer works before touching the next.
Here’s the order I follow.
Before touching anything: read, then branch
I start with the release notes, not the terminal. Flutter publishes a breaking changes page for each release, with migration guides for anything that was removed or behaves differently. If I’m jumping several versions, I read every release in between. It saves a lot of guessing later.
Then I write down the current state:
- Flutter and Dart versions (
flutter --version) - Gradle wrapper, Android Gradle Plugin (AGP) and Kotlin versions
compileSdk,targetSdk,minSdk- iOS deployment target and the Xcode version the project last built with
Then I create an upgrade branch I can throw away if things go sideways.
Pin the SDK per project with FVM
flutter upgrade changes your global SDK. That’s fine if you have one project. If you have five, upgrading one silently breaks the other four.
I use FVM so each project declares its own Flutter version:
fvm install 3.x.y # the target release
fvm use 3.x.y # writes .fvmrc for this project
fvm flutter --version # confirm
The .fvmrc file gets committed, so teammates and CI use the same version. If you don’t use FVM, at least set the environment constraint in pubspec.yaml and check the version in CI, so a mismatch fails loudly instead of quietly producing a different build.
If the jump is big, I don’t go straight to the latest release. I step through intermediate versions, getting a green build at each one. More stops, but each stop has a handful of errors instead of dozens.
Let the tools fix what they can
After moving the SDK, the first command I run is:
dart fix --dry-run
dart fix --apply
dart fix knows about many API deprecations and renames and rewrites your code for you. The dry run shows what it plans to change, which is worth a glance before you apply it. Then flutter analyze to see what’s left. Deprecation warnings aren’t errors yet, but they’re a list of things that will break in a future release, so I clear the easy ones now.
Commit. Build both platforms. Run the tests. Only then move on.
Packages: see what’s behind before upgrading anything
flutter pub outdated
This shows four columns for every dependency: what you’re on, what you could upgrade to within your current constraints, what’s resolvable, and the latest version. The gap between “Resolvable” and “Latest” is where the work is.
My routine:
flutter pub upgradeto take every compatible minor and patch update. Build, test, commit.- For major versions, go one package at a time. Read the package’s changelog, bump it, fix the breakage, commit.
flutter pub upgrade --major-versionsexists and will update all your constraints in one go. I use it on small projects, but on anything substantial it creates the forty-errors-at-once situation I’m trying to avoid.
Watch for packages that are discontinued or haven’t been updated in a long time. pub outdated flags discontinued ones. If a package is blocking an upgrade because it doesn’t support the new SDK, that’s the moment to replace it, ideally behind a small wrapper so the swap touches as few files as possible. I talk about that pattern more in taking over an existing Flutter app.
The Android side
This is where most upgrade pain actually lives, because four tools have to agree with each other: the Gradle wrapper, AGP, Kotlin and the JDK. AGP has a minimum Gradle version, Kotlin has a range of supported AGP versions, and newer Flutter releases eventually stop supporting old ones.
The trick I rely on: generate a fresh project with the new Flutter version and diff it against yours.
flutter create --org com.example scratch_app
Then compare scratch_app/android with your android folder. The fresh project shows you exactly which Gradle, AGP and Kotlin versions this Flutter release expects, and how the build files are supposed to look today.
To bump the wrapper, use Gradle itself rather than editing the properties file by hand:
cd android
./gradlew wrapper --gradle-version <version-from-fresh-project>
Moving to the declarative plugins block
Older Flutter projects applied the Flutter Gradle plugin with apply from: scripts. Current projects use the declarative plugins {} block, and support for the old style has been removed. If your settings.gradle still looks imperative, migrate it. In Kotlin DSL, settings.gradle.kts ends up looking like this:
pluginManagement {
val flutterSdkPath = run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val path = properties.getProperty("flutter.sdk")
require(path != null) { "flutter.sdk not set in local.properties" }
path
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
// Copy these versions from a freshly generated project
id("com.android.application") version "<agp-version>" apply false
id("org.jetbrains.kotlin.android") version "<kotlin-version>" apply false
}
include(":app")
And in app/build.gradle.kts, apply id("dev.flutter.flutter-gradle-plugin") in the plugins {} block after the Android and Kotlin plugins.
SDK levels
For compileSdk and targetSdk, I generally use flutter.compileSdkVersion and flutter.targetSdkVersion unless a plugin needs something higher. Bumping targetSdk is not just a number change, though. Each Android release changes behavior for apps that target it (permissions, background restrictions, notification rules), so read the behavior changes for the new level and test those features on a real device. Google Play enforces a minimum target level for updates, so this one has a real deadline.
The iOS side
iOS upgrades are usually calmer, with a few recurring snags:
- Xcode. New Flutter releases eventually require a newer Xcode, and App Store submissions require a recent SDK.
flutter doctortells you if your Xcode is too old. - Deployment target. Flutter raises its minimum iOS version over time, and plugins do too. Set it in the
Podfile(platform :ios, '...') and in the Runner target, and keep them consistent. A mismatch between the two produces confusing warnings. - CocoaPods. After an SDK bump, I delete
ios/Podsandios/Podfile.lock, then runpod install --repo-update(or justflutter build ios, which runs it). Stale pods cause a surprising share of “it builds on my machine” bugs. - Swift Package Manager. Flutter has been adding Swift Package Manager support for plugins, enabled with
flutter config --enable-swift-package-manager. Plugins that support it are resolved through SwiftPM, the rest fall back to CocoaPods. I treat enabling it as its own separate change, never bundled with an SDK upgrade, so if something goes wrong I know exactly why.
As with Android, a freshly generated project is a useful reference for what ios/Runner and the Podfile should look like now.
One change per commit
Every step above ends with “commit”. That’s the most important habit in this whole post.
A typical upgrade branch is a series of small commits: bump the SDK, apply dart fix, upgrade compatible packages, upgrade one major package, bump Gradle and AGP, migrate the plugins block, bump targetSdk, raise the iOS deployment target.
When a bug shows up a week later, git bisect lands on a commit that changed one thing. A single “upgrade everything” commit leaves you nothing to bisect.
Let CI be the referee
Local builds lie. You have cached artifacts, a different JDK, and environment variables you forgot you set. A CI pipeline builds from scratch every time, which is exactly what you want after an upgrade.
My minimum pipeline for an upgrade branch: analyze, run tests, build a release Android bundle, build iOS without code signing. If the pipeline reads the Flutter version from .fvmrc, local and CI can’t drift apart. I describe how I set that up in Flutter CI/CD with Azure DevOps.
Then I do a manual pass on real devices for the things CI can’t see: permissions, push notifications, deep links, payments, anything touched by the new targetSdk. After that it goes out through the normal release process, ideally a staged rollout so problems reach a small group first.
Make it boring by making it regular
The real fix for painful upgrades is doing them often. A small upgrade every release or two is a short, dull job. An upgrade after a year and a half is a project. I put it on the maintenance calendar like a car service.
Takeaways
- Read the breaking changes for every release you’re crossing before running a single command.
- Pin the SDK per project with FVM and commit
.fvmrc, so teammates and CI match. - Use
dart fix,flutter pub outdatedand a freshly generated project to show you what changed. - On Android, Gradle, AGP, Kotlin and the JDK must agree. Copy versions from a new template and migrate to the declarative
plugins {}block. - One change per commit, and let CI build from scratch to confirm each one.
- Upgrade little and often. Leave it too long and a short chore turns into a whole project.
If your Flutter app is several versions behind and you’d rather not do the catch-up alone, get in touch.
Comments
Questions, corrections or your own experience — leave a comment below (GitHub sign-in).