
Solidity Storage Layout and Packing: Designing Efficient State Variables
Why storage layout matters
Every state variable in Solidity is stored in contract storage, which is organized into 32-byte slots. The compiler decides where each variable goes, and that decision affects:
- Gas usage: fewer storage writes and better packing can reduce costs.
- Correctness: changing variable order can corrupt data in upgradeable contracts.
- Interoperability: tools, proxies, and low-level integrations often depend on stable layouts.
- Debugging: understanding slot assignment makes it easier to inspect state with block explorers or RPC calls.
For most developers, storage layout becomes critical when a contract grows beyond a simple prototype. If you are using inheritance, structs, mappings, arrays, or upgradeable patterns, you need a clear mental model of how Solidity stores data.
How Solidity assigns storage slots
Solidity stores state variables sequentially, starting from slot 0, but it does not always use one full slot per variable. The compiler tries to pack smaller values into the same 32-byte slot when possible.
Basic slot assignment rules
- Each storage slot is 32 bytes.
- Variables are placed in declaration order.
- Value types smaller than 32 bytes may share a slot.
- A variable that does not fit in the remaining space starts a new slot.
- Complex types such as mappings and dynamic arrays use special slot rules.
Consider this contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract StorageExample {
uint256 public a; // slot 0
uint128 public b; // slot 1, lower 16 bytes
uint128 public c; // slot 1, upper 16 bytes
bool public d; // slot 2, 1 byte
address public e; // slot 2, 20 bytes
}Here, a occupies slot 0. The uint128 values b and c can share slot 1. The boold and addresse can share slot 2 because together they fit within 32 bytes.
Packing behavior in practice
Packing is most effective when you group small types together:
booluint8,uint16,uint32,uint64,uint128address- small enums
However, packing is not always a free win. If you frequently update one packed field, the EVM may need to read-modify-write the entire slot, which can reduce the benefit. Packing should be used intentionally, not blindly.
Storage layout for complex types
Not all types are stored directly in their declared slot.
Fixed-size arrays
A fixed-size array stores elements sequentially starting at its assigned slot. If the array elements are smaller than 32 bytes, they may also be packed within slots.
uint128[2] public values; // occupies one slot if packed togetherDynamic arrays
A dynamic array stores its length at the declared slot. The actual elements are stored starting at:
keccak256(slot)For example, if a dynamic array is declared at slot 3, its elements begin at keccak256(3).
Mappings
Mappings do not store keys directly in storage. Instead, each value is stored at:
keccak256(abi.encode(key, slot))The mapping’s declared slot acts as a namespace seed.
mapping(address => uint256) public balances;If balances is assigned slot 5, then balances[user] is stored at keccak256(abi.encode(user, 5)).
Structs
Struct fields are stored sequentially, and packing rules apply inside the struct as well.
struct Position {
uint128 amount;
uint64 openedAt;
bool active;
}If used as a state variable or inside an array, the struct’s fields are packed where possible. This makes structs a useful way to group related data while keeping layout predictable.
A practical packing strategy
A good storage design balances gas efficiency with readability and future flexibility. The main idea is to place small, frequently used values together and keep large or dynamic values separate.
Example: optimized account state
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract AccountRegistry {
struct Account {
uint128 credit; // 16 bytes
uint64 lastUpdated; // 8 bytes
bool active; // 1 byte
address owner; // 20 bytes
}
mapping(address => Account) private accounts;
function setAccount(
address user,
uint128 credit,
uint64 lastUpdated,
bool active
) external {
accounts[user] = Account({
credit: credit,
lastUpdated: lastUpdated,
active: active,
owner: user
});
}
function getAccount(address user) external view returns (Account memory) {
return accounts[user];
}
}This struct is compact, but note that address owner may force a new slot depending on the exact field order and remaining space. If gas efficiency is critical, you should measure the actual layout rather than assuming a theoretical arrangement.
When packing helps most
Packing is most useful when:
- many fields are read together
- fields are updated together
- the contract stores large numbers of records
- the contract is write-heavy and storage costs dominate
Packing is less useful when:
- fields are updated independently and frequently
- code clarity matters more than marginal gas savings
- the contract is expected to evolve often
Common storage layout pitfalls
Changing variable order
Reordering state variables changes storage slots. In a normal contract, this may simply break assumptions in your code. In an upgradeable contract, it can permanently corrupt existing state.
Bad example:
contract V1 {
uint256 public total;
address public owner;
}
contract V2 {
address public owner;
uint256 public total;
}If V2 is used behind a proxy that already stores V1 data, the values will be interpreted incorrectly.
Inheritance affects layout
State variables from base contracts are stored before variables in derived contracts, following the linearized inheritance order. This means adding a new variable to a base contract can shift storage in child contracts.
contract Base {
uint256 internal x;
}
contract Child is Base {
uint256 internal y;
}If Base changes later, Child’s layout may shift as well.
Packed fields and partial updates
When multiple values share a slot, updating one field requires Solidity to preserve the others. This is safe, but it means the compiler performs extra work. In some cases, a single uint256 may be cheaper and simpler than several packed smaller fields.
Dynamic data inside structs
A struct containing dynamic types such as string or bytes is more complex. The struct itself stores references or inline data depending on size, and the dynamic content is stored elsewhere. This makes low-level inspection harder and increases the chance of mistakes when using assembly.
Storage layout and upgradeable contracts
Storage layout is especially important in proxy-based systems. The implementation contract can change, but the proxy’s storage remains fixed. Any mismatch between versions can break the application.
Best practices for upgrade-safe storage
- Append new variables only at the end.
- Never reorder existing variables.
- Never remove variables; deprecate them instead.
- Keep inheritance chains stable.
- Reserve storage gaps for future expansion.
A common pattern is to include a storage gap:
uint256[50] private __gap;This reserves space for future variables without changing the positions of existing ones.
Example upgrade-safe layout
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract VaultV1 {
address public owner; // slot 0
uint256 public totalAssets; // slot 1
uint256[50] private __gap; // reserved space
}Later, you can add new variables before the gap, not before existing fields.
Comparing storage design options
| Design choice | Benefit | Trade-off |
|---|---|---|
| Pack small values together | Lower storage footprint | fewer slots | Harder to read and maintain |
Use uint256 for most fields | Simpler layout | fewer surprises | May waste storage space |
| Group related fields in structs | Better organization | easier reasoning | Struct changes can affect layout |
| Use mappings for sparse data | Efficient lookup | no iteration cost for storage | No direct enumeration |
| Reserve storage gaps | Upgrade safety | Uses unused reserved slots |
Inspecting storage layout during development
You should verify layout rather than relying on intuition. Solidity tooling can help.
Compiler output
The Solidity compiler can emit storage layout metadata. This is useful for audits, upgrade reviews, and debugging.
RPC inspection
You can inspect raw storage using JSON-RPC methods such as eth_getStorageAt. This is especially helpful when validating proxy state or confirming how a variable is packed.
Testing layout assumptions
Write tests that confirm behavior after deployment and after upgrades. For example:
- verify that existing values remain unchanged after an implementation upgrade
- confirm that packed fields decode correctly
- check that mappings and arrays resolve to expected slots
Using assembly carefully
Sometimes you need direct slot access for advanced optimization or interoperability. Solidity exposes storage pointers, and inline assembly can read or write specific slots.
function readSlot0() external view returns (uint256 value) {
assembly {
value := sload(0)
}
}This is powerful but risky. Use it only when you fully understand the layout and have tests covering the exact storage structure. Assembly bypasses many compiler safeguards and can make future refactoring dangerous.
Practical guidelines for production contracts
Prefer clarity first
If a contract is small, use straightforward variable types and order them logically. Premature packing can make code harder to review without meaningful savings.
Pack intentionally
Group fields that are:
- small
- related
- updated together
A common arrangement is:
uint128or smaller counters- timestamps
- flags
- addresses
- large numeric values or dynamic data
Keep upgradeability in mind
If there is any chance the contract will be upgraded, treat storage layout as part of the public interface. Document it, test it, and avoid changes that shift existing slots.
Use structs for domain models
Structs make it easier to reason about grouped state such as user profiles, positions, orders, or vault records. They also help reduce the number of top-level state variables.
Measure before optimizing
Storage packing can reduce gas, but the actual benefit depends on access patterns. Benchmark the contract with realistic transactions before making layout decisions purely for theoretical efficiency.
Summary
Solidity storage layout determines how state is encoded, accessed, and preserved across contract versions. By understanding slot assignment, packing rules, and the behavior of mappings, arrays, and structs, you can design contracts that are both efficient and safe.
The key takeaway is simple: storage is not just an implementation detail. It is part of your contract’s long-term architecture. Plan it carefully, verify it with tooling, and treat changes to layout with the same caution you would apply to a database schema migration.
