Optimize Solidity Storage Slots for Gas Savings
Gas costs in the EVM are not negotiable. The virtual machine charges exactly 20,000 gas to write a non-zero value into a previously-zero storage slot. It charges 5,000 gas to overwrite an existing non-zero value.

A single contract can pay hundreds of thousands of gas in storage operations per transaction. The cost compounds across user activity. A poorly laid-out contract bleeds gas on every interaction, forever. Storage optimization is not a luxury. It is a baseline requirement for production-grade contracts.
The EVM does not approximate. It charges. Every byte written is a deterministic gas cost.
The Mechanics of EVM Storage Slots and Variable Packing
Slot allocation rules
The EVM storage model is straightforward. The contract's storage is a contiguous key-value space. Each key is a 256-bit integer. Each value is a 256-bit (32-byte) word. There are 2²⁵⁶ possible slots. In practice, a contract uses only a handful.
Solidity allocates state variables to these slots in declaration order. The compiler does not rearrange variables across declarations. If a developer writes uint256 a; uint8 b;, the compiler places a in slot 0 and b in slot 1. Even though b occupies only 1 byte, it consumes an entire 32-byte slot.
This is the source of waste. A single uint8 followed by unrelated variables wastes 31 bytes of slot space. Over many variables, the waste compounds into a measurable gas tax on every transaction that touches state.
Packing patterns
Solidity provides automatic packing. When consecutive variables fit within 32 bytes, the compiler packs them into a single slot. The rules:
- Variables are packed in declaration order.
- The first variable in a slot occupies the low-order bytes.
- Variables cannot straddle slot boundaries.
- The total packed size per slot cannot exceed 32 bytes.
The practical patterns:
| Declaration pattern | Slot 0 | Slot 1 | Slots used |
|---|---|---|---|
uint128 a; uint128 b; | a (low 16), b (high 16) | unused | 1 |
uint256 a; uint128 b; | a | b (low 16) | 2 |
address x; uint96 y; | x (20 bytes), y (12 bytes) | unused | 1 |
uint8 a; uint8 b; uint8 c; | up to 32 packed bytes | unused | 1 |
uint256 a; uint8 b; uint256 c; | a | b (low), c (high) | 3 |
The last row is a classic mistake. The uint8 between two uint256 values cannot pack with either. It occupies a slot on its own. The two uint256 values each occupy separate slots. Three slots for three variables. One of them is 31 bytes of waste.
Packing limits
Packing has limits. Arrays, mappings, and dynamic data types always occupy a fresh slot for their own pointer or length. Struct fields can pack internally, but the struct itself follows the parent layout rules.
Inherited contracts introduce another layer. Storage variables from parent contracts occupy lower-numbered slots than variables declared in the child contract. Reordering inheritance changes slot assignments. This is a silent source of layout bugs — the compiler does not warn about inheritance reordering, and the consequence is production state corruption.
Analyzing SSTORE and SLOAD Costs in Modern Ethereum
SSTORE cost tiers
SSTORE is the dominant gas cost in most state-mutating contracts. The opcode charges based on the slot's transition state:
- Zero to non-zero: 20,000 gas
- Non-zero to non-zero: 5,000 gas
- Non-zero to zero: refund of 2,900 gas (post-EIP-3529)
- Zero to zero: 100 gas (no-op)
Cold access costs apply on top of these figures if the slot has not been touched in the current transaction. After Berlin (EIP-2929), cold SSTORE costs an additional 2,100 gas. Warm SSTORE does not.
The numbers matter at scale. A contract that writes five packed slots on every user interaction pays 25,000 gas in SSTORE alone on warm paths. Cut that to four slots through better layout, and the savings materialize across every transaction the contract processes for its entire lifespan.
SLOAD costs
SLOAD is cheaper but not free:
- Cold SLOAD: 2,100 gas
- Warm SLOAD: 100 gas
In realistic access patterns, gas costs for storage operations run into five-figure numbers per write. Reading is cheaper but not negligible. A contract that reads 20 storage slots per function call pays 4,200 gas (cold) or 2,000 gas (warm) on read alone. Packing more data into fewer slots reduces the SLOAD count just as it reduces the SSTORE count.
EIP-3529 impact
EIP-3529 (London hard fork, August 2021) cut the maximum gas refund from 50% of the transaction cost to a fixed 2,900 gas per cleared slot. The total refund is also capped at 20% of the transaction's total gas usage. The motivation: prevent abuse by gas token contracts that cleared storage to harvest refunds at scale.
The consequence: clearing a slot is no longer a viable gas optimization. Developers cannot rely on delete to recover gas. The benefit is reduced and sometimes negative. The current refund structure favors leaving slots intact rather than aggressively clearing them.
Pre-EIP-3529, gas token contracts could profit from delete operations. Post-EIP-3529, the refund is a fraction of its former value. Optimization strategies built on storage clearing are obsolete.Practical cost modeling
To estimate storage costs in a real contract, walk the write path of each public or external function. Count the SSTORE operations. Assign each a cost tier based on whether the slot transitions from zero, from non-zero, or to zero. Sum the costs. Repeat for SLOAD operations. The result is the function's storage gas floor — the minimum gas the function will consume on storage access alone, regardless of computation.
This floor is deterministic. Given the same state and the same inputs, the storage gas cost is identical on every call. It does not fluctuate with network conditions. It does not depend on block fullness. It is a fixed property of the contract's code and layout.
Strategic Ordering of State Variables for Efficient Layouts
Co-mutation grouping
Storage layout is a deterministic function of declaration order and type sizes. To minimize gas, the developer must order variables with three goals:
1. Pack frequently co-mutated variables into the same slot.
2. Isolate variables with different access patterns to avoid unnecessary writes.
3. Reserve small types for slots where packing density is high.
Consider a vault contract tracking user balances and metadata. The natural struct:
struct UserInfo {
uint128 balance;
uint128 rewardDebt;
uint64 lastStakeTime;
address referrer;
}If balance and rewardDebt update together on every claim, they pack into a single 32-byte slot. The remaining fields fit into a second slot. Total: 2 slots per user.
If the declaration order were balance; referrer; rewardDebt; lastStakeTime, the layout would split balance and rewardDebt across separate slots. Each claim would write two slots instead of one. Gas cost on the SSTORE path doubles.
Access patterns dictate the layout. Static analysis of the contract's write paths is the prerequisite. Without it, layout decisions are guesses.
Dynamic type isolation
A secondary consideration: dynamic types. Arrays and mappings always occupy a fresh slot for their own data pointer. Nested structs follow their own packing rules independently of the parent contract. A struct field of mapping type breaks packing at that slot boundary.
The practical rule: declare all fixed-size, packable variables before any dynamic types in each contract or struct. This maximizes the number of variables that benefit from packing and avoids the gap that a dynamic type pointer creates between surrounding declarations.
Upgrade constraints
When upgrading via proxy, layout drift is a critical concern. The new implementation must preserve the storage layout of the old implementation. Adding variables, reordering variables, or changing types breaks the upgrade. Storage collisions corrupt state. Audit tools must verify this invariant at every upgrade.
A common upgrade bug: inserting a new state variable in the middle of an existing declaration. The new variable shifts all subsequent variables to higher slot numbers. The proxy contract still points to the same storage. The new variable overlaps with what the old contract considered a different field. State corruption is silent and permanent.
The safe pattern: append new variables only at the end of the contract's declaration list. Never insert. Never reorder. Never change types. Use OpenZeppelin's storage gap convention (uint256[50] __gap;) to reserve space for future additions without risking layout drift.
Auditing Storage Layouts with solc and Hardhat Plugins
Compiler tooling
The Solidity compiler emits storage layout information. The flag --storage-layout produces JSON output that maps each variable to a slot and offset within that slot.
Invocation: solc Contract.sol --combined-json storage-layout
The output includes, for each variable:
slot: the slot numberoffset: byte offset within the slottype: the variable's typelabel: the variable's name
This information is sufficient to audit the layout manually. The slot and offset values directly indicate whether variables pack as intended. Any variable occupying an unexpected slot or sitting at offset 0 with unused bytes after it is a packing candidate.
Hardhat plugin
For automated audits, the hardhat-storage-layout plugin extends Hardhat builds. It generates per-contract artifacts at compile time. Each artifact is a JSON file containing the full storage map. The artifact persists in the build output and survives across runs.
The plugin enables comparison between contracts. An upgrade audit compares the old implementation's storage map against the new implementation's storage map. Any mismatch in slot numbers or offsets is a layout collision warning.
Audit workflow
A practical audit covers five areas. First, verify packing density: confirm that consecutive variables with compatible sizes share slots as intended. Second, verify co-mutation grouping: confirm that variables updated in the same function occupy the same slot. Third, verify dead state: scan for variables that are declared but never read or written in any function. Dead state occupies slots and inflates gas costs without contributing to the contract's logic.
Fourth, verify upgrade safety: compare the storage map of the new implementation against the old implementation. Any deviation in slot assignment is a layout collision. Fifth, verify that storage is not used as a substitute for memory. Variables written and discarded within a single function should be declared memory, not storage. Each accidental storage read or write costs more than its memory equivalent.
The audit must also check for implicit masking costs. When a function writes a uint8 value to a packed slot, the EVM performs a read-modify-write cycle: it loads the full 32-byte slot, masks out the target bytes, inserts the new value, and writes the full slot back. If the slot contains variables that are not being updated, the read-modify-write still touches the entire slot. This is unavoidable for packed layouts, but the developer should be aware that packing does not eliminate the cost of partial writes — it merely reduces the number of slots affected.
The uint256 Paradox: Why Smaller Types Often Cost More Gas
Masking overhead
Conventional wisdom holds that smaller types save gas. In Solidity, this wisdom is mostly wrong.
The EVM operates on 256-bit words. Every arithmetic operation happens on a full word. When the operation involves a smaller type, the EVM masks the value before and after the operation to ensure correctness.
The mask operation is not free. Each AND instruction costs 3 gas. Loading and storing smaller types may require additional masking. The overhead can exceed the savings.
For local variables and function arguments, uint256 is the optimal type. The EVM does not need to mask. Arithmetic runs at native speed.
Storage vs. local context
For storage variables, the analysis differs. The cost of packing smaller types into a single slot is weighed against the cost of using multiple slots. If packing reduces the number of slots written per transaction, packing wins. If packing complicates access patterns or requires additional masking in SLOAD/SSTORE, packing loses.
Concrete example: a setSmall function that assigns a uint8 value to storage. The uint8 smallValue occupies a slot. The assignment writes a 32-byte slot, even though only 1 byte is meaningful. The SSTORE costs 20,000 gas (cold) or 5,000 gas (warm, non-zero to non-zero). The 1-byte payload does not reduce the cost.
If smallValue were part of a packed slot shared with other variables, the cost would still be one SSTORE per slot mutation. Packing does not reduce the SSTORE cost. It reduces the number of SSTORE operations. The distinction is critical.
Type selection rules
The correct type selection depends on context:
- Function parameters: prefer
uint256unless external interface constraints require otherwise. - Local variables: prefer
uint256. - Storage variables: prefer the smallest type that packs efficiently with neighbors.
- Loop counters and accumulators: prefer
uint256. - Struct fields designed for packing: prefer the smallest type that satisfies the value range, grouped with fields that share access patterns.
The uint256 paradox resolves into a simple rule: align with the EVM's word size where possible. Pack only where storage layout demands it. Smaller types in storage are a means to packing efficiency, not an end in themselves.
A related case: using bool versus uint8. Both occupy 1 byte in storage. The bool type compiles to the same SLOAD/SSTORE as uint8. The difference is zero in storage. In memory, both are packed into 32 bytes by the compiler. The two are equivalent in practice for gas purposes. Choose bool for semantic clarity; choose uint8 when the contract needs arithmetic on the field.
Final Perspective
Storage layout is a contract's long-term gas footprint. The footprint is set at compile time. Once deployed, the layout is immutable. There is no runtime fix, no patch, no migration path that changes where a variable sits in storage without a full proxy upgrade — and even that carries its own risks.
The optimization process is deterministic. Every decision — declaration order, type sizing, packing strategy, upgrade safety — follows directly from the EVM specification and the Solidity compiler's layout rules. There is no ambiguity. There is no "it depends." There is the spec, the compiler output, and the gas cost.
Storage layout decisions are permanent. The gas consequences are permanent. Audit before deployment, not after the damage is done.
The developer who understands storage slots does not guess. The developer reads the compiler output, verifies packing against intent, benchmarks against realistic state, and ships a contract that does not waste a single byte.
FAQ
Why does changing the order of variables in a contract affect gas costs?
Does using smaller data types like uint8 always save gas?
Can I use the delete keyword to recover gas after EIP-3529?
How can I safely add new variables to a contract that uses a proxy?
How can I verify my contract's storage layout?
By Caleb North