The ritual humiliation
Your first deploy to the App Store or Play Console works. You celebrate. You make a small fix, hit Deploy again, and get:
ERROR ITMS-90189: "Redundant Binary Upload. ... bundle version 1"
# or
versionCodeTooLow: Version code 42 has already been used.
Welcome to the most-Googled deploy error of all time. What's going on?
Two numbers, two purposes
Every Apple and Google app has two version identifiers that travel together, and most developers routinely confuse them:
- Version / versionName: the human-facing string.
"1.2.3". Shown on the app's Store listing. Apple calls thisCFBundleShortVersionString; Android calls itversionName. You pick whatever makes marketing sense — SemVer, calendar versioning, whatever. - Build number / versionCode: an integer. Apple calls this
CFBundleVersion; Android calls itversionCode. This is the one the store uses to order uploads.
The rule both stores enforce: every upload must have a strictly greater build number than every previous upload for the same user-facing version (Apple) or ever (Google).
Why stores need a monotonic integer
It's tempting to read "just bump the number" as arbitrary gatekeeping. It's not. Three concrete things depend on it:
1. Unambiguous rollback ordering. When you discover a crash in build 47 and need to revert, the store needs to know which build is "older." Comparing integers is unambiguous; comparing semver strings is not (1.2.0 vs 1.2.10 — string comparison says 1.2.10 < 1.2.2). Both Apple's TestFlight rollback and Google Play's staged-rollout halt rely on integer comparison.
2. Crash-report aggregation. Firebase Crashlytics, App Store Connect's crash reporting, Google Play Console's vitals — all of them bucket crash instances by (versionName, build). If you let two uploads share a build number, one bucket holds crashes from two different binaries, and the aggregation lies. The constraint exists so every crash report points at exactly one binary.
3. Device-side upgrade logic. Android's package manager upgrades an installed app only if the incoming APK's versionCode is strictly greater than the installed one. If you upload the same versionCode twice, even users who notice the "update available" prompt can't actually install it over the existing build — because by the OS's rule, it is the existing build.
Why Apple requires it per-Version and Google requires it forever
There's a subtle difference worth internalizing:
- Apple: build numbers must be strictly increasing within a given user-facing Version. You can have
1.2.3 (42)then1.2.3 (43), and later ship1.3.0 (1)— Apple resets the counter scope when you move to a new Version string. - Google: versionCode must be strictly increasing across all uploads ever, regardless of versionName. Once you've shipped versionCode 100, you can never ship 99 again — even under a different versionName.
Google's rule is simpler to reason about. Apple's is more forgiving if you want to reset the counter at each marketing release. Most mature projects ignore the scoping and just keep a global counter on both platforms.
Two strategies that actually work
Strategy 1 — tie the build number to time. Use $(date +%Y%m%d%H%M) as your build number. Monotonic by definition, reset concerns solved, and you can recover the upload time by looking at the number. Downside: large numbers (>2 billion on Android blows past Integer.MAX_VALUE), so some teams cap at $(date +%Y%m%d)%04d and pad a sequence suffix.
Strategy 2 — bump automatically on every archive. Use agvtool next-version -all on Apple, or a gradle regex on Android's build.gradle. This is what LingCode's Auto-bump build number and Auto-bump versionCode checkboxes do — rewrite the project file before the archive step, so the bump is durable and committed to your repo.
Symptoms and fixes
- "Redundant Binary Upload" / ITMS-90189 — Apple. Bump
CFBundleVersion(the Build field in Xcode's target General tab), not Version. - "versionCodeTooLow" — Google. Bump
versionCodeinapp/build.gradle'sdefaultConfig. - Upload succeeds but the Store still shows the old build — rare, but usually means you uploaded a new binary with the same Version and Build numbers. The Store accepted the upload but deduplicated it against the existing record. Bump and retry.
The takeaway
Build numbers aren't metadata — they're the primary key each store uses to order your uploads. The stricter-seeming rule (Google's "forever monotonic") is easier to live with than the more forgiving rule (Apple's "per-Version") because you stop having to think about scopes. Pick a strategy, automate it, and forget about it.