
Optimizing Solidity Contracts with `unchecked` Arithmetic
Why arithmetic checks cost gas
Before Solidity 0.8, arithmetic overflow could silently wrap. Since 0.8, the compiler inserts checks around operations like +, -, *, and ++. If the result would overflow or underflow, the transaction reverts.
That behavior is safer, but it means every arithmetic operation includes extra logic. In tight loops, counters, or accounting code where the valid range is already proven by invariants, those checks become unnecessary overhead.
The unchecked block tells the compiler to skip overflow and underflow checks for the enclosed arithmetic operations.
unchecked {
total += amount;
}Use it only when you can prove the arithmetic cannot overflow or underflow under valid execution paths.
When unchecked is a good fit
unchecked is most useful when the code already enforces a strict bound.
Common safe scenarios
- Loop counters that are guaranteed to stop before overflow
- Decrementing a value after checking it is nonzero
- Adding values with a known upper limit
- Internal accounting where totals are bounded by supply, balance, or array length
Typical examples
- Iterating over
uint256 ifrom0tolength - 1 - Decreasing a balance after validating
balance >= amount - Increasing a counter that cannot reach
type(uint256).maxin practice, such as a small array index or bounded nonce
The key idea is simple: if the surrounding logic already proves safety, the compiler does not need to prove it again on every operation.
A practical example: loop increments
The most common use of unchecked is in for loops. Solidity checks i++ on every iteration, even when the loop condition already guarantees that i stays within range.
Baseline version
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract SumExample {
function sum(uint256[] calldata values) external pure returns (uint256 total) {
for (uint256 i = 0; i < values.length; i++) {
total += values[i];
}
}
}This is perfectly correct, but i++ incurs an overflow check each time.
Optimized version
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract SumExample {
function sum(uint256[] calldata values) external pure returns (uint256 total) {
uint256 length = values.length;
for (uint256 i = 0; i < length; ) {
total += values[i];
unchecked {
++i;
}
}
}
}Why this is safe
istarts at0iincreases by exactly1- The loop stops when
i == length lengthis auint256, so the loop cannot run long enough to overflowiin any realistic execution
This pattern is especially useful in functions that process arrays, batches, or fixed-size collections.
A practical example: guarded subtraction
Another common case is decrementing a value after a prior check.
Baseline version
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract Vault {
mapping(address => uint256) public balances;
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "insufficient balance");
balances[msg.sender] -= amount;
}
}The subtraction is safe because the require ensures the balance is large enough.
Optimized version
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract Vault {
mapping(address => uint256) public balances;
function withdraw(uint256 amount) external {
uint256 balance = balances[msg.sender];
require(balance >= amount, "insufficient balance");
unchecked {
balances[msg.sender] = balance - amount;
}
}
}This saves the compiler from inserting a redundant underflow check.
Important note
Do not move the subtraction into unchecked unless the validation happens first and cannot be bypassed. If future refactoring changes the control flow, the safety guarantee may disappear.
Where unchecked is not appropriate
unchecked is not a general optimization switch. It should not be used just because a function is “hot” or because gas savings sound attractive.
Avoid unchecked when:
- The arithmetic depends on user input without a strong bound
- The code path is complex and safety is hard to reason about
- The value may be reused across multiple branches
- The operation is part of critical accounting that must never silently wrap
Risky example
unchecked {
totalSupply += mintedAmount;
}This is only safe if mintedAmount and totalSupply are bounded by a strict invariant. Otherwise, a wraparound could corrupt supply accounting and break the contract.
In financial contracts, correctness usually matters more than marginal gas savings. If the proof of safety is not obvious, keep the checked arithmetic.
Comparing checked and unchecked arithmetic
| Pattern | Safety | Gas cost | Best use case |
|---|---|---|---|
| Default arithmetic | High | Higher | General-purpose contract logic |
unchecked increment in loops | High if bounded | Lower | Array iteration, batch processing |
unchecked subtraction after require | High if validated first | Lower | Withdrawals, balance updates |
unchecked on unbounded user input | Low | Lower | Not recommended |
The table highlights the tradeoff: unchecked reduces gas, but only when the surrounding code makes overflow impossible.
How much gas can it save?
The exact savings depend on the compiler version, optimization settings, and surrounding code. In practice, the benefit is usually modest per operation but meaningful in aggregate.
Best candidates for measurable savings
- Loops that run many times
- Batch operations over arrays
- Repeated counter increments in internal logic
- Tight accounting paths executed frequently by users
For a single arithmetic operation, the savings may be small. For a loop that runs hundreds of times, removing the check from each iteration can add up.
Rule of thumb
Use unchecked where:
- the operation is repeated often, and
- the safety proof is simple and local
If the code is executed rarely, the readability tradeoff may not be worth it.
Best practices for safe usage
1. Keep the unchecked block as small as possible
Do not wrap large sections of code. Limit the block to the exact arithmetic that is safe without checks.
unchecked {
++i;
}This makes the intent clear and reduces the chance of accidental misuse.
2. Prove safety with nearby checks
If subtraction is unchecked, place the validating require immediately before it.
require(balance >= amount, "insufficient balance");
unchecked {
balance -= amount;
}This makes the relationship between the check and the arithmetic obvious.
3. Prefer local variables for clarity
When a value is read from storage and then updated, cache it in a local variable first. This improves readability and makes the invariant easier to inspect.
uint256 balance = balances[msg.sender];
require(balance >= amount, "insufficient balance");
unchecked {
balances[msg.sender] = balance - amount;
}4. Document the invariant
A short comment can help future maintainers understand why the code is safe.
// Safe: i is bounded by values.length.
unchecked {
++i;
}5. Test boundary conditions
Write tests for:
- zero values
- maximum expected values
- empty arrays
- exact boundary cases where the
requirepasses or fails
This is especially important when unchecked is used in code that may evolve over time.
A real-world pattern: batch processing
Batch functions are a strong candidate for unchecked because they often combine loop iteration with repeated arithmetic.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract Airdrop {
mapping(address => uint256) public claimed;
function claimBatch(address[] calldata recipients, uint256[] calldata amounts) external {
require(recipients.length == amounts.length, "length mismatch");
uint256 length = recipients.length;
for (uint256 i = 0; i < length; ) {
address recipient = recipients[i];
uint256 amount = amounts[i];
require(claimed[recipient] == 0, "already claimed");
claimed[recipient] = amount;
unchecked {
++i;
}
}
}
}Why this pattern works well
- The loop counter is bounded by
length - The increment is trivial and safe
- The function may execute many iterations, so the per-iteration savings matter
- The business logic remains readable
If the batch size is also capped by design, the safety argument becomes even stronger.
Common mistakes
Mistake 1: wrapping too much code
unchecked {
require(balance >= amount, "insufficient");
balance -= amount;
someOtherFunction();
}This is too broad. Only the subtraction needs unchecked arithmetic. Keep unrelated logic outside the block.
Mistake 2: assuming a check elsewhere still applies
If a value is modified between the check and the arithmetic, the proof may no longer hold.
require(balance >= amount, "insufficient");
balance = adjust(balance);
unchecked {
balance -= amount; // may no longer be safe
}Mistake 3: using unchecked for “performance” without measurement
Not every optimization is worthwhile. If the function is rarely called, the readability cost may outweigh the gas savings.
Mistake 4: using unchecked in public-facing accounting without a clear invariant
For token balances, supply tracking, and fee logic, silent wraparound can be catastrophic. Only use unchecked when the invariant is simple, enforced, and well-tested.
Practical decision guide
Use the following checklist before adding unchecked:
- Is the arithmetic operation provably safe?
- Is the proof local and easy to read?
- Is the operation executed frequently enough to matter?
- Is the
uncheckedblock minimal? - Are tests covering boundary conditions?
If the answer to any of these is “no,” keep the checked arithmetic.
Conclusion
unchecked arithmetic is one of the simplest ways to reduce gas in Solidity 0.8+, but it should be applied surgically. The best use cases are loop counters and guarded arithmetic where the contract already enforces strict bounds.
Treat unchecked as a precision tool, not a blanket optimization. When used carefully, it can improve efficiency without weakening correctness.
