Short answer
Pin the exact Vyper version and use -O2 for production builds, unless your formal verification or audit tooling requires bytecode that maps line-by-line to source — then use -O0 for those artifacts and accept the gas premium. The repeatability risk you describe is not actually caused by the optimizer level; it is caused by compiler version drift, and version pinning solves it for both flags.
Separating the confirmed facts from the assumption
Confirmed behavior: Vyper's optimizer is deterministic for a given compiler version. The same source, same compiler version, and same flags produce identical bytecode every time. So -O2 output is just as reproducible as -O0 output within a pinned toolchain.
The assumption worth challenging is that -O0 buys you immunity from layout surprises. It does not. Storage layout and codegen can still change between compiler versions at -O0; the flag only removes optimizer-induced variance within a version. If your CI ever upgrades Vyper, you face the same review burden either way. Choosing -O0 to avoid future optimizer changes is paying a permanent gas cost to hedge against a problem that version pinning already addresses.
When -O0 is genuinely the right call
- Your verification pipeline (symbolic execution, SMT-based proofs, manual audit diffing) assumes source-correlated bytecode or specific storage-slot assignments.
- You are debugging a suspected compiler bug and want the simplest possible codegen as a baseline.
- The contract is deployed once, rarely called, and gas is irrelevant compared to auditability.
When -O2 wins
- The contract is user-facing and called frequently, so a typical 5–15% gas reduction compounds into real cost. Treat that figure as an estimate, not a guarantee — measure it on your own contract.
- Your verification flow operates on the AST or IR level rather than raw bytecode, so optimizer transformations do not break it.
Making the choice verifiable in CI
Whichever flag you pick, lock the toolchain and prove determinism:
# requirements.txt — pin the exact version, no ranges
vyper==0.4.1
# Compile twice in clean environments and compare
vyper -O2 contract.vy > build1.hex
vyper -O2 contract.vy > build2.hex
diff build1.hex build2.hex && echo "deterministic"
# Inspect the actual storage layout instead of assuming it
vyper -f layout contract.vy
Then measure the real gas delta before committing to a level:
# Compare runtime bytecode size as a quick proxy
vyper -f bytecode_runtime -O0 contract.vy | tr -d '\n' | wc -c
vyper -f bytecode_runtime -O2 contract.vy | tr -d '\n' | wc -c
For accurate gas numbers, run your representative transactions through a local fork or test framework (e.g., Titanoboa, Foundry's Vyper support, or a pytest fixture) under both flags and compare receipts. If the measured saving is under ~3%, the verification simplicity of -O0 likely wins; if it is near or above 10%, -O2 usually justifies the extra audit effort.
One detail that can change the recommendation
Does anything external depend on your storage layout — an upgradeable proxy, a sibling contract reading your slots, or an off-chain indexer decoding raw storage? If yes, that constraint matters more than the optimization flag: pin the layout explicitly, verify it with vyper -f layout in CI on every build, and treat any layout diff as a breaking change regardless of -O0 or -O2.
Bottom line
Lock the Vyper version first — that is where repeatability actually comes from. Then default to -O2 for gas-sensitive contracts and reserve -O0 for artifacts that feed bytecode-level verification. The uncertainty about future optimizer changes is real but manageable: it is an upgrade-time review task, not a reason to pay higher gas forever.