Vyper Reentrancy Protection: Choosing Between @nonreentrant and Manual Guards
Decide when to use Vyper's built-in @nonreentrant decorator versus a manual reentrancy guard, with trade-offs, constraints, and a concrete implementation checklist.
25 Jun 2026, 14:42 UTC

The decision: one-line decorator or explicit lock
Reentrancy happens when an external call hands control back to the caller before your contract finishes updating its own state. In Vyper, you can either apply the built-in @nonreentrant decorator or write a manual storage guard. The decorator is the default choice for single-function protection; a manual guard is necessary when you need a shared lock across multiple functions or finer control around a specific external call.
Constraints and version assumptions
The @nonreentrant decorator is documented for Vyper 0.3.8 and later. Older compilers will reject it. Because compiler internals evolve, verify that your installed version supports the decorator and inspect the generated storage layout before mainnet deployment. Run vyper --version in your terminal to confirm the compiler version.
Comparing the two approaches
| Aspect | @nonreentrant decorator | Manual storage guard |
|---|---|---|
| Availability | Vyper 0.3.8+ (verify locally) | All Vyper versions |
| Code overhead | One line above the function | Explicit state variable plus lock/unlock logic |
| Scope | Entire function body | Can wrap only a specific external call |
| Shared lock across functions | Not supported natively | Possible with a shared variable |
| Failure handling | Compiler-managed | Must reset manually if using try/except or revert_on_failure=False |
| Storage cost | Dedicated slot per decorated function (verify) | One slot can serve multiple functions |
Trade-offs in practice
The decorator reduces boilerplate and the chance of forgetting to release a lock. However, it locks the whole function, which may be broader than necessary. If you only need to guard a single raw_call inside a larger function, a manual guard avoids blocking unrelated state changes during the external call.
Manual guards introduce audit risk. In Vyper, if a low-level call reverts and you catch the failure with raw_call(..., revert_on_failure=False), the surrounding state changes are not automatically rolled back. You must explicitly reset the guard variable, or the contract will remain permanently locked for that path.
Concrete implementations
The following examples illustrate a withdrawal pattern where an external transfer could reenter the contract. Both are written for Vyper 0.3.x syntax.
Option 1: Decorator
# @version ^0.3.8
balances: public(HashMap[address, uint256])
@nonreentrant
@external
def withdraw():
amount: uint256 = self.balances[msg.sender]
self.balances[msg.sender] = 0
send(msg.sender, amount)
Save this as withdraw_decorator.vy. Compile it with vyper -f abi -f bytecode withdraw_decorator.vy from the directory containing the file. The command requires read permission on the source file and a working Vyper installation. Expected check: compilation succeeds without syntax errors.
Option 2: Manual guard
# @version ^0.3.8
balances: public(HashMap[address, uint256])
guard: uint256
@external
def withdraw():
assert self.guard == 0, "Reentrant call"
self.guard = 1
amount: uint256 = self.balances[msg.sender]
self.balances[msg.sender] = 0
send(msg.sender, amount)
self.guard = 0
If you replace send with a raw_call that does not revert on failure, add an explicit reset before the function exits:
success: bool = False
success = raw_call(msg.sender, b"", value=amount, revert_on_failure=False)
if not success:
# handle failed transfer
pass
self.guard = 0
Validation and practical checks
- Compile check: Run
vyper -f abi -f bytecode <filename>.vyin your project directory. Confirm no errors are reported. - Storage inspection: Use
vyper -f layout <filename>.vy(if supported by your version) to verify that the decorator creates a lock slot or that your manualguardvariable occupies the expected storage position. - Reentrancy test: Deploy the contract to a local development network. From a separate attacker contract, call
withdrawand attempt to reenter in the fallback function. The second call should revert with"Reentrant call"for the manual guard; for the decorator, confirm the revert behavior in your compiler version. - Static analysis: Run a linter or static analyzer configured for Vyper to confirm the guard pattern is applied consistently.
Limitations
- Neither approach prevents
delegatecall-based reentrancy or cross-function reentrancy unless a shared manual lock is used across all entry points. - The decorator does not natively support a single lock shared by multiple functions; if
withdrawandclaimmust share one lock, implement a manual guard. - Reentrancy protection complements, but does not replace, the checks-effects-interactions pattern. Update state before making external calls whenever possible.
- The decorator may not be compatible with functions using inline assembly or complex memory layouts; check your compiler documentation.
- Compiler behavior and storage slot naming for the decorator may differ between Vyper releases. Verify against your specific version before relying on storage layout assumptions.
For most single-function withdrawal or transfer patterns, the @nonreentrant decorator is the lower-risk default. Switch to a manual guard when you need a shared lock, partial-function protection, or compatibility with compiler versions that predate the decorator.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.