Video this article refers to:
Previous Article: Source Sets in Android: Clean Debug-Only Diagnostics Without if/else
I used to think debug vs release was enough for most apps.
Then I worked on apps where we had three different backends QA, staging, production, and suddenly “debug vs release” wasn’t the real problem anymore. The real problem was: which environment is this build talking to?
On paper this looks simple, but in real projects, people waste time on things like:
- “Why is login failing on my phone but not yours?”
- “Oh… you installed the prod build by mistake.”
- “Wait, is this pointing to staging or QA?”
So the practical goal here is: make environments a first-class thing in your build system, not a “remember to change this URL” thing.
That’s where product flavors come in.
The three terms beginners confuse and how to remember them
A simple way to think about it is:
Build types = how the app is built debug tools, minify, signing Example:
debug,releaseProduct flavors = which version of the app it is environment, business model, branding Example:
qa,staging,prodBuild variants = the final combinations you can run/install Example:
qaDebug,prodRelease
This is where most people get stuck: they treat flavors as “extra build types”. They’re not. They solve a different problem.
Why flavors exist at all
Build types answer:
“Is this a debug build or a release build?”
Flavors answer:
“Which environment / product version is this build for?”
In many teams you need:
- QA → internal testing
- Staging → UAT / pre-prod validation
- Production → real users
And you want each one to have its own:
- Base URL
BASE_URL - app id so installs don’t overwrite each other
- version suffix so humans can see what it is
What usually happens without flavors is people hardcode a URL, forget to change it, and then debugging becomes a detective story.
Step 1: Flavor dimensions why Gradle asks for this
File: app/build.gradle.kts
android
flavorDimensions += "env"
This snippet is not “random Gradle ceremony”.
It’s Gradle asking you to categorize your flavors.
A dimension is basically a label that says:
“These flavors belong to the same group of choices.”
Here the group is called env environment.
So inside env, you’ll have options like qa, staging, prod.
If you’re a beginner, this is the part that matters: dimensions prevent ambiguity when you later add more flavor groups like billing → free/paid. Gradle needs to know which categories exist so it can generate variants correctly.
Step 2: Creating flavors what the Gradle block is trying to achieve
File: app/build.gradle.kts
productFlavors
create"qa"
dimension = "env"
applicationIdSuffix = ".qa"
versionNameSuffix = "-qa"
buildConfigField"String", "BASE_URL", "\"https://qa.api.example.com\""
create"staging"
dimension = "env"
applicationIdSuffix = ".staging"
versionNameSuffix = "-staging"
buildConfigField"String", "BASE_URL", "\"https://staging.api.example.com\""
create"prod"
dimension = "env"
buildConfigField"String", "BASE_URL", "\"https://api.example.com\""
Let’s break down what this is trying to do, piece by piece.
1 create"qa", create"staging", create"prod"
This defines three “environment versions” of your app.
So you’re telling Gradle:
“Generate builds for QA, staging, and prod.”
2 dimension = "env"
This attaches each flavor to the env dimension you defined earlier.
Meaning: qa, staging, and prod are mutually exclusive choices inside the same category.
3 applicationIdSuffix
This is the one that saves beginners from pain.
applicationIdSuffix = ".qa" means the QA app becomes something like:
com.example.app.qa
So you can install QA and staging and prod side-by-side on the same phone.
In real projects, this avoids the classic problem:
- “I installed staging and it overwrote prod.”
4 versionNameSuffix
This is for humans.
It makes the app version display something like:
1.0.0-qa1.0.0-staging
So when someone sends you a screenshot, you don’t have to guess.
5 buildConfigField"String", "BASE_URL", ...
This creates a constant in BuildConfig:
BuildConfig.BASE_URL
And crucially: each flavor gets its own value.
So you’re no longer switching URLs manually. The build decides it.
The mistake people make is putting URLs in code and changing them before each build. That becomes unreliable the moment more than one person is building the app.
Step 3: Build types still matter they solve a different problem
buildTypes
debug
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
isMinifyEnabled = false
release
isMinifyEnabled = false
signingConfig = signingConfigs.getByName"debug"
This block is trying to keep debug/release differences intact.
- Debug builds usually have dev tooling, logs, no shrinking/minify during early dev.
- Release builds are what you intend to ship signing, optimization, etc..
For me, the clean separation is:
- Flavors decide where the app points QA vs staging vs prod.
- Build types decide how the app behaves debuggability, optimization.
They stack together.
So a variant like qaDebug means:
- QA environment rules + Debug build rules.
Step 4: Proving the variant at runtime what the UI snippet is doing
File: MainActivity.kt
KeyValue"FLAVOR env", BuildConfig.FLAVOR.ifBlank "—"
KeyValue"BUILD_TYPE", BuildConfig.BUILD_TYPE
KeyValue"BASE_URL", BuildConfig.BASE_URL
This is not “UI fluff”. It’s a debugging tool.
It answers three questions immediately:
- Which flavor is running? →
BuildConfig.FLAVOR - Which build type is running? →
BuildConfig.BUILD_TYPE - Which backend will this talk to? →
BuildConfig.BASE_URL
What I’ve noticed is: a tiny “variant info” section like this saves hours, especially when you have multiple builds installed.
And notice the style: no guessing, no checking Gradle, no “trust me it’s staging”. The running app tells you the truth.
Step 5: The variants you get and why the number grows fast
With:
- 3 flavors
qa,staging,prod - 2 build types
debug,release
You get 3 × 2 = 6 build variants:
qaDebugqaReleasestagingDebugstagingReleaseprodDebugprodRelease
If you’re a beginner, this is the part that matters: variants grow multiplicatively.
Add one more flavor? Everything doubles/triples depending on your setup.
So yes, flavors are powerful. But here’s the catch: they can create a lot of builds to manage.
When flavors are worth it and when they’re not
In real projects, flavors are worth it when you truly need separate “editions” of the app, like:
- Multiple environments QA/staging/prod
- Free vs paid
- Partner-branded builds
- Ad-supported vs ad-free
But if you only need something small like “debug hits dev backend, release hits prod”, you might not need flavors at all.
According to me, the best rule is:
Add flavors when the difference is a real product/business requirement — not just a developer convenience.
Because flavor explosion is real, and maintaining many variants can become its own job.
The takeaway
- Build types answer: “debug or release?”
- Flavors answer: “QA, staging, or prod?”
- Variants are the combinations you actually run and install.
And the practical win is: once flavors exist, you stop guessing which build is on a device — the build system and the running app make it obvious.