
Using `unchecked` Blocks in Solidity Safely and Effectively
What unchecked Does
Inside an unchecked block, Solidity skips automatic overflow and underflow checks for arithmetic on uint and int types. This applies to operations such as:
- addition
- subtraction
- multiplication
- division by constants is still checked for division by zero
- increment and decrement
- compound assignments like
+=and-=
Outside unchecked, the compiler inserts runtime checks and reverts on overflow or underflow.
Example
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Counter {
uint256 public count;
function increment() external {
unchecked {
count += 1;
}
}
}In this example, the contract assumes count will never reach type(uint256).max. If that assumption is valid, the unchecked block avoids the overflow check and saves gas.
When unchecked Is Appropriate
unchecked is useful when you can prove the arithmetic cannot overflow or underflow based on surrounding logic.
Common cases include:
- loop counters with known bounds
- arithmetic after explicit range checks
- decrementing a value only after verifying it is nonzero
- accumulator patterns where the maximum possible value is bounded
- performance-sensitive code paths called frequently
Typical use cases
| Scenario | Why unchecked helps | Safety condition |
|---|---|---|
for loop increments | Avoids repeated overflow checks | Loop bound guarantees the counter cannot overflow |
balance -= amount after validation | Saves gas on a verified subtraction | amount <= balance already checked |
| Fixed-size iterations | Reduces overhead in hot paths | Index stays within a known range |
| Packed accounting logic | Minimizes repeated arithmetic checks | Upper bounds are enforced elsewhere |
The key idea is simple: use unchecked only when the contract logic already guarantees correctness.
A Practical Example: Loop Optimization
A common and safe use of unchecked is incrementing a loop counter when the loop condition itself guarantees termination before overflow.
Safe version
function sum(uint256[] memory values) external pure returns (uint256 total) {
for (uint256 i = 0; i < values.length; i++) {
total += values[i];
}
}This is correct, but the compiler checks i++ on every iteration.
Optimized version
function sum(uint256[] memory values) external pure returns (uint256 total) {
for (uint256 i = 0; i < values.length; ) {
total += values[i];
unchecked {
++i;
}
}
}Why this is safe:
istarts at 0- the loop stops when
i == values.length - array lengths cannot exceed
type(uint256).max - therefore
++icannot overflow before the loop ends
This pattern is especially useful in functions that iterate over arrays, sets, or fixed-length data structures.
A Practical Example: Safe Decrement After Validation
Another common pattern is subtracting only after a precondition check.
Example
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Vault {
mapping(address => uint256) public balances;
function withdraw(uint256 amount) external {
uint256 current = balances[msg.sender];
require(current >= amount, "insufficient balance");
unchecked {
balances[msg.sender] = current - amount;
}
}
}Here, the subtraction is safe because current >= amount is checked first. Without unchecked, Solidity would perform the same safety check again during subtraction, which is redundant.
This pattern is common in:
- token accounting
- escrow balances
- reward claims
- internal credit/debit systems
Best practice
Keep the validation and the arithmetic close together. If the code becomes more complex, future maintainers may miss the safety assumption.
Avoiding Common Mistakes
unchecked is not a general optimization tool. It is a precision instrument. The most common mistakes are subtle and often appear in code that “looks obviously safe” at first glance.
1. Using unchecked without a proof
unchecked {
total += amount;
}This is unsafe unless you can prove total + amount cannot overflow. If amount comes from user input and total is unbounded, the operation can wrap around and corrupt state.
2. Moving validation away from the arithmetic
require(amount <= balances[msg.sender], "too much");
doSomethingElse();
unchecked {
balances[msg.sender] -= amount;
}If doSomethingElse() can change state or introduce reentrancy, the original validation may no longer be valid. Keep the check and the subtraction tightly coupled.
3. Assuming unchecked disables all safety checks
unchecked only affects arithmetic overflow and underflow checks. It does not disable:
requirestatements- array bounds checks
- division-by-zero checks
- type conversion restrictions
- external call failures
That means unchecked is narrower than many developers expect.
4. Using it in code that may be refactored later
A loop that is safe today may become unsafe after a future change to its bounds or inputs. If you use unchecked, document the invariant clearly so later changes do not break it.
Document the Invariant, Not Just the Code
The most important best practice is to explain why the arithmetic is safe. A future reviewer should not need to reconstruct the proof from scratch.
Good example
// Safe because i < values.length and array length fits in uint256.
unchecked {
++i;
}Better example
// Safe because `current >= amount` was checked above and both values are uint256.
unchecked {
balances[msg.sender] = current - amount;
}This kind of comment is valuable in audit reviews and maintenance work. It turns a hidden assumption into an explicit contract invariant.
Choosing Between Checked and Unchecked Arithmetic
Not every arithmetic operation deserves optimization. In many contracts, the gas saved by unchecked is too small to justify the added risk.
| Use checked arithmetic when | Use unchecked when |
|---|---|
| Inputs are not tightly bounded | You can prove the result stays in range |
| The code is rarely executed | The code is in a hot path |
| Readability matters more than micro-optimization | The operation is repeated many times |
| The logic may change frequently | The invariant is stable and well documented |
| Security review should be straightforward | The proof is simple and local |
A good rule: start with checked arithmetic, then optimize only where profiling or code review shows a meaningful benefit.
Patterns That Benefit Most
Some Solidity patterns are especially good candidates for unchecked.
Loop counters
Loop increments are the most common safe optimization. The counter is usually bounded by array length or a fixed iteration count.
Balance updates with prior checks
If a function already validates sufficient balance, the subtraction can often be placed inside unchecked.
Internal accounting with capped totals
Protocols that enforce strict caps on supply, rewards, or allocations may safely use unchecked in internal math after validation.
Fixed-step arithmetic
If a value is incremented by a constant and the maximum number of steps is known, unchecked may be appropriate.
Patterns That Should Usually Stay Checked
Some operations should remain checked unless you have a very strong reason and a formal proof.
User-controlled accumulation
If an attacker can repeatedly increase a value, wrapping can become exploitable.
Cross-function state dependencies
If arithmetic depends on state modified by multiple functions, proving safety becomes harder.
Complex financial logic
In lending, liquidation, or reward distribution code, correctness is more important than a small gas reduction.
Code with evolving requirements
If the logic is likely to change, checked arithmetic is safer and easier to maintain.
Testing unchecked Logic
Because unchecked removes runtime protection, tests become more important. You should verify both the expected path and the boundary conditions.
Recommended tests
- minimum values
- maximum values
- zero values
- exact boundary values where subtraction becomes valid or invalid
- loop termination behavior
- repeated execution near limits
Example test ideas
- withdrawing the full balance succeeds
- withdrawing more than the balance reverts before the
uncheckedsubtraction - a loop over an empty array returns immediately
- a loop over a large array completes without overflow in the counter
If you use fuzz testing, target the invariants that justify the unchecked block. For example, fuzz the amount and balance relationship to ensure the precondition always protects the subtraction.
A Maintainable Template
When you do use unchecked, keep the pattern consistent and easy to audit.
function transfer(address to, uint256 amount) external {
uint256 fromBalance = balances[msg.sender];
require(fromBalance >= amount, "insufficient balance");
unchecked {
balances[msg.sender] = fromBalance - amount;
balances[to] += amount;
}
}This style works well because:
- validation happens first
- the unsafe arithmetic is isolated
- the code is short and readable
- the invariant is easy to verify
If the recipient balance could overflow in your application, the second line should remain checked or be guarded by a separate cap. Do not assume both sides of a transfer are equally safe.
Practical Guidelines
Use this checklist when deciding whether to introduce unchecked:
- Can you state the invariant in one sentence?
- Is the invariant enforced immediately before the arithmetic?
- Is the code path performance-sensitive enough to justify the change?
- Will future maintainers understand why it is safe?
- Do tests cover the boundary conditions?
- Would a simpler checked version be acceptable?
If the answer to any of these is “no,” keep the checked arithmetic.
Summary
unchecked is one of Solidity’s most useful low-level tools, but it should be applied surgically. It is best suited for arithmetic that is already proven safe by surrounding logic, especially in loops and validated balance updates.
The safest approach is to treat every unchecked block as a small formal claim: the code inside cannot overflow or underflow because the surrounding logic guarantees it. If you can document and test that claim clearly, unchecked can improve gas efficiency without sacrificing correctness.
