Storage layout
Solidity state variables live in storage. So far you've been declaring them and using them without thinking about where they actually sit. They sit in a giant array of 32-byte slots, with 2^256 slots in total, indexed starting from 0. The compiler decides which variable goes in which slot, and for dynamic types like mappings and arrays the slot derivation involves hashing. This lesson walks through how storage is actually laid out: how the compiler packs small types together, where dynamic arrays put their elements, where mappings put their values, and the security implication that follows from all of this. Anyone can read any slot of any contract. Marking a state variable private does not make it secret.Storage is an array of slots
A contract's storage is logically a flat array. Each entry is one slot. A slot is 32 bytes (256 bits) wide. The array has 2^256 slots, all initialized to zero. State variables get assigned positions in this array when the contract is compiled, starting from slot 0 in declaration order.
Consider this contract:
// Solidity 0.8.24, Ethereum mainnet
contract Sample {
uint256 a = 123;
uint128 b = 10;
uint128 c = 20;
}The compiler walks the variables in order. a is 32 bytes wide, so it takes a full slot. It gets slot 0. Next is b, which is 16 bytes wide. It can't fit in slot 0 because slot 0 is already taken in full by a, so b starts at slot 1 in the low 16 bytes. Next is c, also 16 bytes wide. The high 16 bytes of slot 1 are still free, so c fits there. b and c share slot 1.
The packing rule: adjacent state variables get packed into the same slot if their combined size fits in 32 bytes. The first variable in declaration order occupies the LOW bytes. Each subsequent packed variable occupies the next higher bytes upward.
This matters for gas. Every storage slot you write costs gas. A slot that holds two 128-bit values costs the same gas to update as one that holds a single 256-bit value. Pack your state correctly and you save real money. The order of declaration controls this. If you write uint128 b; uint256 a; uint128 c; instead, the compiler can't pack b and c together because a sits between them. You end up using three slots instead of two. Declare same-size and small variables next to each other so the compiler has a chance to pack them.
Reading storage from outside
Storage isn't private to the contract. Every full node holds a copy of every contract's storage. Any client can ask any node for the contents of any slot via the JSON-RPC method eth_getStorageAt(contractAddress, slot).
From a Hardhat test using viem:
// Hardhat 3 with viem
const slot0 = await publicClient.getStorageAt({
address: sample.address,
slot: 0n,
});
const slot1 = await publicClient.getStorageAt({
address: sample.address,
slot: 1n,
});
console.log("slot 0:", slot0);
console.log("slot 1:", slot1);For the example contract above this prints:
slot 0: 0x000000000000000000000000000000000000000000000000000000000000007b
slot 1: 0x000000000000000000000000000000140000000000000000000000000000000aSlot 0 reads as 0x...7b, where 7b is the hex of 123, the value of a. The leading zeros are just the slot's 32-byte width.
Slot 1 reads as 0x...0014...000a. Two values are visible inside the slot. The high 16 bytes hold 0x14, which is 20 in decimal, the value of c. The low 16 bytes hold 0x0a, which is 10 in decimal, the value of b. They share the slot exactly as the diagram showed.
Note that the contract didn't expose a, b, or c as public. The variables aren't decorated with any visibility modifier at all. They could even be marked private. None of that changes what eth_getStorageAt returns. The data is in storage, the storage is on chain, the chain is public.
Dynamic arrays
Fixed-size types fit in a known number of slots, so the compiler assigns them positions at compile time. Dynamic types like uint256[] and mapping(K => V) can grow at runtime, so the compiler can't know in advance how many slots they'll need. The Solidity spec handles this by giving each dynamic type a main slot at the usual declaration-order position and then placing the actual contents at slots derived through hashing.
Take this contract:
// Solidity 0.8.24, Ethereum mainnet
contract Sample {
uint256 a = 123;
uint256[] arr;
constructor() {
arr.push(10);
arr.push(20);
}
}a takes slot 0. The dynamic array arr gets slot 1 as its main slot. But the main slot doesn't hold the array elements. It holds only the array length. The elements live somewhere else entirely.
For a dynamic array at main slot p, the elements are stored starting at slot keccak256(p). The first element is at keccak256(p), the second at keccak256(p) + 1, the i-th at keccak256(p) + i. The keccak256 output is a 256-bit number, so the elements land at some unpredictable position in the slot array, typically nowhere near the other state variables.
Reading this out from a test:
import { keccak256, pad, toHex } from "viem";
// the main slot just holds length
const main = await publicClient.getStorageAt({
address: sample.address,
slot: 1n,
});
// main = 0x...02
// derive where the elements start
const baseSlot = keccak256(pad(toHex(1n)));
const first = await publicClient.getStorageAt({
address: sample.address,
slot: baseSlot,
});
// first = 0x...0a (= 10)
const second = await publicClient.getStorageAt({
address: sample.address,
slot: toHex(BigInt(baseSlot) + 1n, { size: 32 }),
});
// second = 0x...14 (= 20)The main slot tells you how long the array is. The hash gives you where to start reading. Anyone who knows the contract's address and the main slot number can read the whole array.
Mappings
Mappings use a similar derivation but include the key. For a mapping declared at main slot p, the value at key k is stored at slot keccak256(k . p), where . means concatenation. Both k and p are padded to 32 bytes before being concatenated, then the 64-byte result is hashed.
// Solidity 0.8.24, Ethereum mainnet
contract Sample {
uint256 a = 123;
uint256[] arr;
mapping(address => uint256) balances;
constructor() {
arr.push(10);
arr.push(20);
balances[msg.sender] = 100;
}
}The mapping balances is the third state variable, so it gets slot 2 as its main slot. But the main slot stays at zero forever. Mappings don't track length, since every possible key already conceptually "exists" with the default value 0.
Three consequences fall out of this design.
Missing keys return zero by default. When you read balances[someAddress] for an address that was never assigned to, the EVM computes the slot, reads it, finds zero, and returns it. There's no separate "key exists" check. The contract can't distinguish "I never set this" from "I set this to zero." If you need that distinction, store a separate boolean flag.
Mappings can't be enumerated. There is no list of which keys have been written. Iterating would require enumerating every possible key, which is 2^160 addresses or 2^256 uints. The values are spread across the storage space at hash-derived positions with no index. If you need to iterate, maintain a separate array of keys alongside the mapping and update both whenever you write.
Mappings can't be returned from functions. A return value has to be a finite blob of data. A mapping is logically a function from every possible key to a value. There is no sensible serialization. If you want to expose mapping contents externally, expose a getter for a specific key.
Reading a mapping value from a test:
import { encodeAbiParameters, keccak256 } from "viem";
// derive slot for balances[someAddress]
const valueSlot = keccak256(
encodeAbiParameters(
[{ type: "address" }, { type: "uint256" }],
[someAddress, 2n]
)
);
const balance = await publicClient.getStorageAt({
address: sample.address,
slot: valueSlot,
});
// balance = 0x...64 (= 100)encodeAbiParameters here takes care of padding the address to 32 bytes and the slot number to 32 bytes, then concatenating them. keccak256 hashes the 64-byte result. The output is the slot where Solidity stores the value.
Nested types compose
The same rules compose. A mapping(address => uint256[]) stores the dynamic array at the slot derived from the address key, and that array's elements live at further slots derived from THAT slot. A mapping(address => mapping(uint256 => bool)) derives a slot from the outer key, then derives again from the inner key.
Each level of dynamic indirection is one more keccak256 step. The chain is deterministic. Anyone holding the keys can compute the final slot and read it.
"Private" doesn't mean private
The private keyword in Solidity controls who can reference the variable in source code. A private uint256 secret cannot be referenced by name from another contract that imports yours. The Solidity compiler enforces this at compile time.
Storage doesn't know any of that. Storage is a flat array of bytes that the EVM persists between transactions. There is no access control at the storage level, and there couldn't be. Every full node holds a copy of every slot of every contract, and any client can ask any node for any slot via eth_getStorageAt. The private keyword is a Solidity visibility modifier. It is not a security feature.
The common mistake looks like this:
// Solidity 0.8.24, Ethereum mainnet
// DO NOT USE — this is the bug being demonstrated
contract VulnerableLock {
address private owner;
uint256 private secretCode;
constructor(uint256 _secretCode) {
owner = msg.sender;
secretCode = _secretCode;
}
function unlock(uint256 attempt) external {
require(attempt == secretCode, "wrong code");
// do something privileged
}
}The deployer believes secretCode is hidden because it's private. It isn't. Anyone watching the deployment transaction can already see the constructor argument in calldata. After deployment, anyone can read secretCode straight off the chain:
cast storage <lock_address> 1 --rpc-url <node_url>
# 0x0000000000000000000000000000000000000000000000000000000000000539
# that's the secret in hex (0x539 = 1337)One JSON-RPC call. No special access. No bypass. The variable was always public. The keyword private only meant "can't be referenced by name from another Solidity contract."
This is a real, recurring source of audit findings. Sealed RNG seeds, commit values, off-chain authentication codes, internal pricing data, partial private keys split across slots: all have been stored as private state variables and read straight off the chain.
The actual options for keeping a value secret on chain are limited. The value can live off chain entirely, with the contract verifying a hash or signature from an off-chain source. You can use commit-reveal: store keccak256(value, salt) during a commit phase, then have the user reveal value and salt later. The commit is on chain but the preimage stays hidden until reveal. For richer guarantees, cryptographic schemes like zero-knowledge proofs let a contract verify properties of a value without ever seeing the value itself.
There is no fourth option called "make it private." Storage is public.