Background Mobile

Gas Optimisation by Storage Layout

backend development/
September 17, 2026
Gas Optimisation by Storage Layout

Storage slot packing is one of the most reliable ways to cut gas costs in a Solidity contract. It costs nothing to implement if you catch it before deployment, and it can be expensive to fix after the fact. This post walks through how the EVM storage model works, where layout decisions hurt you, and how to audit and fix a contract's slot arrangement before it goes live.

How the EVM Storage Model Actually Works

Every contract gets a key-value store. Keys are 256-bit slot indices; values are 256-bit words. Reading a slot that has never been written costs 2,100 gas (EIP-2929, post-Berlin). Writing a zero-to-nonzero slot costs 22,100 gas. Writing a nonzero-to-nonzero slot costs 2,900 gas. Those numbers dominate the cost profile of most contracts.

The Solidity compiler assigns storage variables to slots in declaration order, starting at slot 0. Variables smaller than 32 bytes get packed into the same slot if they fit sequentially. If you break the sequence with a 32-byte type, the packing stops and a new slot begins.

A uint128 followed by a uint128 occupies one slot. A uint128 followed by a uint256 followed by another uint128 occupies three slots. The middle uint256 forces the second uint128 out of slot 0.

That is the entire root cause of most storage layout bugs.

What Does a Badly Laid-Out Struct Actually Cost?

Consider a struct that represents a lending position:

struct Position {
    address owner;       // 20 bytes  → slot 0 (bytes 0–19)
    uint256 principal;   // 32 bytes  → slot 1 (full slot)
    bool isActive;       // 1 byte    → slot 2 (byte 0)
    uint96 interestRate; // 12 bytes  → slot 2 (bytes 1–12)
    uint256 collateral;  // 32 bytes  → slot 3 (full slot)
    uint64 lastUpdated;  // 8 bytes   → slot 4 (bytes 0–7)
}

This struct uses 5 slots. Rearranged:

struct Position {
    uint256 principal;   // slot 0
    uint256 collateral;  // slot 1
    address owner;       // slot 2 (bytes 0–19)
    uint96 interestRate; // slot 2 (bytes 20–31)
    uint64 lastUpdated;  // slot 3 (bytes 0–7)
    bool isActive;       // slot 3 (byte 8)
}

Now it uses 4 slots. In a function that reads the full struct, you save one SLOAD (2,100 gas cold, 100 gas warm). Across thousands of calls per day on a high-throughput protocol, that is meaningful. In a high-frequency DeFi contract on a congested network, the savings compound quickly.

The general rule: put uint256 and bytes32 types together at the top, then pack smaller types in descending size order at the bottom.

How Do You Audit an Existing Contract's Storage Layout?

The fastest tool is forge inspect. Running:

forge inspect src/MyContract.sol:MyContract storage-layout --pretty

gives you a table of every variable, its slot, byte offset, and type. You can also use hardhat-storage-layout for Hardhat projects. Both work against compiled artefacts, so you don't need to instrument the contract.

For inherited contracts, the layout gets more complicated. Solidity linearises the inheritance chain (C3 linearisation) and assigns slots to base contracts first, in the order they appear in the is clause. If a base contract has five variables and a derived contract adds two more, the derived contract's variables start at slot 5. That is fine, but if you add a variable to the base contract after deployment, every derived contract's layout shifts by one slot. This is a known footgun when upgrading with OpenZeppelin's TransparentUpgradeableProxy or UUPSUpgradeable.

Storage Gaps

OpenZeppelin solves the inheritance upgrade problem with storage gaps:

uint256[50] private __gap;

This reserves 50 slots in each base contract so future variable additions don't shift derived storage. It is a blunt but reliable convention. If your contract uses upgradeable patterns, check whether every base contract declares a gap. If it doesn't, adding a variable to a deployed base is a breaking change.

Mappings and Dynamic Arrays

Mappings don't consume their slot linearly. A mapping at slot p stores the value for key k at keccak256(k . p). Dynamic arrays store their length at slot p, and elements start at keccak256(p). None of this is affected by slot packing directly, but it means mappings and arrays should sit at slots that won't conflict after a layout change. Analysing this matters when you're writing migration scripts or using assembly to read storage directly.

/// Not sure where to start?

Get the architecture before you commit

Tell us what you're building and we'll map the technical approach, stack, and rough timeline. No cost, no obligation, no sales call required.

Packing Booleans and Small Integers Without Shooting Yourself

Packing is not always a win. Reading a packed variable requires a bitmask and a shift operation. The compiler emits these automatically, but the cost is real: roughly 3–6 extra gas per read compared to reading an aligned uint256. If a variable is read far more often than it is written, and reads are warm (same transaction), the overhead of masking can erase the savings from fewer SLOADs.

The crossover point depends on access patterns:

Scenario Pack?
Variable read once per tx, written rarely Yes — slot reduction wins
Variable read 10+ times per tx, hot path Measure — masking overhead may dominate
Variable in a view function called off-chain Irrelevant — view calls cost 0 in execution gas
Variable in a tight loop (e.g., batch operations) Profile with forge snapshot before deciding

For booleans specifically: if you have eight or fewer boolean flags on a struct that is always read together, packing them into a uint8 bitmap and using bitwise ops is almost always better than eight separate bool fields. If they are read independently across different functions, the case is weaker.

Constant and Immutable Variables Don't Touch Storage

This one gets missed more often than it should. Variables declared constant are inlined at compile time. Variables declared immutable are written once at construction and stored in the contract's bytecode, not in storage slots. Reading either costs around 3 gas (a PUSH or similar), not 2,100.

If you have a value that never changes after deployment (an oracle address, a fee percentage, a token address), it should be immutable, full stop. Using a storage variable for it burns 2,100 gas on the first read every time a new transaction context touches it.

The distinction matters at scale. A protocol with 10,000 daily transactions reading a storage-based fee tier pays roughly 21,000,000 gas per day in unnecessary SLOADs. At 30 gwei on Ethereum mainnet, that is about 0.63 ETH per day.

Conclusion

Storage layout is a compile-time decision with runtime consequences that compound over every transaction your contract ever processes. Audit the layout with forge inspect before deployment. Pack small types together, put uint256 variables at the top, use immutable for anything that doesn't change, and add storage gaps to every base contract in an upgradeable hierarchy.

If you're working on a contract that's already deployed and the layout is suboptimal, the path forward is a migration to a new implementation via a proxy upgrade. It's not trivial, but it's the only option short of redeployment.

The next concrete step: run forge inspect on your most gas-sensitive contract today and compare the slot count against a rearranged version. If the difference is more than two slots, the rework is worth the time.


FAQ

Does storage layout matter on L2s like Arbitrum or Optimism? Yes, though the absolute gas costs are lower. Arbitrum charges for L1 calldata and L2 execution separately. Storage reads still cost gas on the L2 execution layer, and contracts with poor layout pay more on every call. The relative savings from packing are similar to mainnet.

Can the Solidity compiler automatically optimise storage layout? No. The compiler packs variables that fit sequentially, but it does not reorder declarations to minimise slot count. That is the developer's responsibility. Tools like forge inspect show you the current layout, but they don't suggest improvements automatically.

What happens to storage layout after a proxy upgrade? The implementation contract's storage layout must be append-only and compatible with the proxy's existing layout. Adding variables at the end is safe. Changing the type or position of existing variables corrupts storage silently. Use a layout compatibility checker like OpenZeppelin's hardhat-upgrades plugin to catch regressions before they reach mainnet.

Is there a gas cost to declaring a storage gap? No. Unused uint256[N] gap arrays cost nothing at runtime because the slots are never read or written. The only cost is the slot reservation itself, which only matters if you're approaching the theoretical 2^256 slot limit (you aren't).

When should I use a struct vs. separate variables? Use a struct when the fields are always read or written together, because Solidity can load a full struct in one SLOAD sequence if it's packed well. Separate variables make sense when fields are accessed independently across different functions, since loading a struct to read one field wastes warm SLOAD credits on fields you don't need.

Have a project in mind? Contact Sodio Technologies to discuss your requirements and explore the right technology solution for your business.

/// Work with us

Talk to the engineers who'd build it

You'll get a technical scope, timeline and cost estimate from the people doing the work, not an account manager. In-house team, no subcontracting, since 2016.

Contact Us