Versioning an Expo app: version vs buildNumber vs versionCode
Three numbers that look interchangeable and aren’t. Get them wrong and you learn about it at the worst possible time — mid-upload.
1. version — the one users see
expo.version in app.json is the marketing version (“1.4.0”). It shows in
the store listing and becomes iOS’s CFBundleShortVersionString and Android’s
versionName. Bump it when you ship to users.
2. ios.buildNumber — the one the store tracks
expo.ios.buildNumber maps to CFBundleVersion. App Store Connect refuses a
second upload with the same build number, so it must increase on every upload —
even a fix for the same user-facing version. And it must be **period-separated integers**
(12 or 1.4.0, never 1.4-beta).
3. android.versionCode — an integer that only goes up
expo.android.versionCode is a plain integer Google Play uses to order uploads.
Same rule: strictly increasing, never reused.
The classic mistake
You bump version to 1.4.0, re-upload to fix a typo in the listing, and get:
ERROR: The bundle version must be higher than the previously uploaded version.
Because you bumped the marketing version and not the build number. They’re separate on purpose.
The fix: let CI own the build number
Don’t type these by hand — derive them so they can’t go backwards. In app.config.js:
export default {
expo: {
version: '1.4.0',
ios: { buildNumber: String(process.env.BUILD_NUMBER ?? '1') },
android: { versionCode: Number(process.env.BUILD_NUMBER ?? 1) },
},
};
Then feed BUILD_NUMBER from your CI run number. Every upload gets a fresh, larger
number automatically — no more “already used”.
Check it before you upload
Missing or malformed build numbers are a common App Store bounce. ipa-check reads a built .ipa and
tells you if CFBundleVersion is missing or isn’t the right shape.
npx @njweber/ipa-check ./YourApp.ipa
Get new posts
One email when a new post ships. No spam.