Every release you ship to Google Play carries two version identifiers, and mixing them up is one of the most common — and most avoidable — release mistakes in Android development. Getting your android version code strategy right affects how Play orders your builds across tracks, how staged rollouts behave, and whether your CI pipeline produces sane, traceable artifacts. Here is the full breakdown.
versionCode vs versionName: what each one does
defaultConfig {
applicationId = "com.example.myapp"
versionCode = 42 // integer — Play's source of truth for ordering
versionName = "2.1.0" // string — what users see
}
-
versionCode: a positive integer that must increase monotonically with every release you upload. Google Play uses it — and only it — to decide whether build B is newer than build A. It is invisible to users. -
versionName: a display string shown in Play Store listings and app info screens. Play does not parse it for ordering;"10.0"is not considered newer than"9.0"unless itsversionCodeis higher.
The rule that bites people: Play rejects any upload whose versionCode is not greater than the highest versionCode already uploaded for that app — including builds on internal, closed, or open testing tracks, and even discarded drafts. A "failed" upload still consumes its version code.
How Play uses version codes across tracks
Tracks are not isolated numbering spaces. The version code is global per app:
- If your internal track has versionCode
50and production has45, promoting internal → production works: 50 > 45. - If production has
50and your closed track has48, promoting closed → production fails until you build a closed-track release with versionCode51+. - Staged rollouts halt and resume against the same versionCode; a hotfix during a paused rollout needs a higher versionCode, which starts a new release.
This is why versioning strategy must be designed once, globally, rather than improvised per track.
Automating version bumps in CI
Manual version bumps are where mistakes happen — someone forgets, or two branches both claim versionCode 51. Automate it. A robust pattern is to derive the versionCode from your CI build number plus a track-safe offset:
// build.gradle.kts — versionCode derived from CI, never hand-edited
val buildNumber = System.getenv("BUILD_NUMBER")?.toIntOrNull() ?: 1
val versionMajor = 2
val versionMinor = 1
val versionPatch = 0
defaultConfig {
// Encodes 2.1.0 + build number into a single increasing integer:
// 2_01_00_0001 style — adjust digit widths to your needs
versionCode = versionMajor * 1_000_000 + versionMinor * 10_000 + versionPatch * 100 + (buildNumber % 100)
versionName = "$versionMajor.$versionMinor.$versionPatch ($buildNumber)"
}
Rules for the scheme you choose:
-
Monotonic across all branches and tracks. A build from
mainmust never get a lower code than a build from a feature branch uploaded earlier. Using a single CI counter (GitHub Actionsrun_number, JenkinsBUILD_NUMBER) guarantees this. -
Room to grow. Reserve digit widths generously. versionCode is a 32-bit int (max 2,100,000,000 in practice on Play) — you will not run out, but a cramped scheme like
major*100 + minor*10 + patchbreaks the moment you need build numbers. - Traceable. Anyone looking at a versionCode should be able to map it back to a commit or CI run. Embed the build number or use a scheme documented in your repo README.
For versionName, follow semantic versioning (MAJOR.MINOR.PATCH) for user-facing releases, and append the build number or commit hash for test-track builds so QA can identify exactly what they are testing.
Common versioning mistakes
Reusing a versionCode after a rejected upload. The upload failed validation, so you fix the bug and re-upload with the same versionCode. Rejected. The code was consumed the moment the bundle reached Play, successful or not. Always bump.
Resetting versionCode to 1 for a "fresh start." Play will reject every subsequent upload until you exceed the historical maximum. There is no reset; the counter only moves forward for the lifetime of the app.
Different versionCodes per flavor that collide. If you ship free and pro flavors of the same app listing, their versionCodes share one global sequence. Coordinate them — e.g., derive both from the same CI counter — or promotions will fail mysteriously.
versionName implying an order Play ignores. Shipping versionName "2.10" with a lower versionCode than "2.9" means users on 2.9 never get the "newer" 2.10. Always let CI assign both from the same source of truth.
Forgetting the 32-bit ceiling in exotic schemes. Timestamp-based versionCodes (yyyyMMddHHmm as an integer) work but eat the int range fast and break if your CI clock skews. Prefer counter-based schemes.
A versioning checklist for your pipeline
- [ ] versionCode assigned automatically in CI from a single monotonic counter
- [ ] Scheme documented in the repo (digit widths, max values, mapping to builds)
- [ ] versionName follows semver for production, with build identifiers on test tracks
- [ ] All flavors/variants share one global versionCode sequence
- [ ] Release checklist includes verifying versionCode > current production before upload
- [ ] Team knows rejected uploads still consume version codes
Versioning is plumbing: invisible when it works, catastrophic when it does not. A one-time investment in CI-driven android version code management pays for itself the first time a hotfix needs to ship at midnight — and it eliminates an entire class of "promotion failed" mysteries from your release process. And if your release pipeline needs a professional once-over — versioning, signing, and Play Console configuration — the team at Play Console Store helps developers get their publishing workflow into shape.
Author bio: Alex Rivera is an Android engineer and technical writer covering Play Console policy, build tooling, and release engineering. He writes on behalf of Play Console Store, an independent marketplace for Android publishing services — not affiliated with or endorsed by Google LLC.
Top comments (0)