Deep dive · Deploy fundamentals

Why versionCode breaks your second deploy

Build numbers aren't there to annoy you. They're the primary key both stores use to order time itself.

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:

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:

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

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.