You deploy the contract. You call the setter. You watch the gas estimate come back roughly twice what you sketched on a napkin, and nothing in the logic explains it. The struct has four fields. They look innocent. But the order you wrote them in is quietly billing every user who touches that function, and it will keep doing that forever.
This piece explains exactly why.
The 32-Byte Box Everything Lives In
The Ethereum Virtual Machine stores state in a key-value store where every slot is exactly 32 bytes wide. 256 bits. One slot costs 20,000 gas to write from zero to non-zero (a cold SSTORE), and 2,900 gas to update an existing non-zero value. Read a cold slot: 2,100 gas. These numbers come from EIP-2929, which repriced storage access after the Berlin hard fork.
Solidity's compiler tries to be clever about this. If two or more variables fit together inside 32 bytes, the compiler packs them into the same slot, so a single SSTORE covers both. If they don't fit, or if you've ordered them in a way that breaks the packing, each variable bleeds into its own slot and you pay per slot.
That's the entire mechanic. Everything else is a consequence.
How the Compiler Decides What Gets Packed
Solidity assigns storage slots sequentially, top to bottom, in the order you declare variables or struct fields. It tries to fill each slot before opening a new one. The rule is strict: if the next variable doesn't fit in the remaining space of the current slot, it starts a fresh slot. It will not rearrange your declarations to be clever. Think of it less like a smart packer and more like a postal worker who stuffs boxes in the order items arrive on the conveyor belt.
Take two developers, Priya and Marco, who both write a staking contract with the same four fields: a `uint128` balance, a `uint128` stakedAmount, a `uint256` timestamp, and a `bool` active. Priya writes her struct in that order. Marco writes it as `uint256` timestamp first, then `uint128` balance, then `bool` active, then `uint128` stakedAmount.
Priya's layout:
- Slot 0: `balance` (16 bytes) + `stakedAmount` (16 bytes) = full slot, one SSTORE.
- Slot 1: `timestamp` (32 bytes) = one SSTORE.
- Slot 2: `active` (1 byte) = one SSTORE, 31 bytes wasted.
Total slots: 3. Total cold writes: 3 SSTOREs.
Marco's layout:
- Slot 0: `timestamp` (32 bytes) = full slot.
- Slot 1: `balance` (16 bytes) + `active` (1 byte, 15 bytes remaining) = one slot, but `stakedAmount` is 16 bytes and won't fit in the remaining 15, so...
- Slot 2: `stakedAmount` (16 bytes) = new slot.
Also 3 slots, technically. But here's where it matters: if a frequent operation updates only `balance` and `stakedAmount` together (standard staking math), Priya's contract does it in 1 SSTORE. Marco's does it in 2. Across thousands of transactions, that difference compounds into a real protocol cost. The structs look nearly identical. The gas bills don't.
Found a struct you maintain? Count its slots. Pack four small fields into two or fewer, and you're ahead.
Reading Costs Just As Much As Writing
People fixate on write costs because 20,000 gas is an attention-grabbing number. Reads are not free. A cold SLOAD (first access to a slot in a transaction) costs 2,100 gas since EIP-2929. A warm SLOAD costs 100 gas.
If your view function reads `balance` and `stakedAmount` from Priya's packed struct, the EVM issues one SLOAD and extracts both values via bitmasking and shifting. One cold read: 2,100 gas. The same read against Marco's separated layout issues two cold SLOADs: 4,200 gas. For a read-heavy contract like an AMM pricing function called inside a larger transaction, those reads stack up fast.
The EVM's bitmasking to extract a packed value isn't free either. Unpacking a `uint128` from the lower half of a slot costs a few extra opcodes (AND, SHR). That overhead runs in tens of gas, not thousands. The slot access dominates. Always.
The Case for Deliberately NOT Packing
Here's where honest analysis diverges from the usual gas-golf advice, and I'll say it plainly: packing is not a universal good. It's a tool that cuts in both directions depending on access patterns.
Writing to one field in a packed slot requires the EVM to read the entire slot first (to preserve the other fields), modify the relevant bytes, then write the whole slot back. That's a read-modify-write cycle. If the slot is cold, you pay 2,100 gas for the read on top of the write cost. For two variables that update independently on different code paths, separate slots can actually be cheaper per individual operation.
Consider a struct with a `uint248` field and a `bool`. They fit perfectly into 32 bytes. But if the boolean is a flag toggled by governance votes (rare) while the uint is updated on every single user transaction (constant), packing them means every user transaction pays for a cold SLOAD to read the slot before writing the uint. Unpacking them lets the hot path hit only one slot without touching the other.
This is the tradeoff most tutorials skip entirely. Pack variables that move together. Separate variables that move independently on hot paths. The tidiness of a perfectly packed struct can cost more than the slot it saves.
Structs in Mappings Add One More Layer
When your struct lives inside a mapping (the common case: `mapping(address => UserStake)`), each struct instance starts at a deterministic storage slot computed by `keccak256(key . baseSlot)`. From that anchor, fields lay out sequentially in the same packed order. The packing rules are identical. The only difference is that every struct instance is always cold on its first access in a transaction, so the slot-count math hits full cold prices every time.
So ask yourself: a contract with 50,000 users calling a function that reads two fields from their struct will issue either 50,000 or 100,000 cold SLOADs depending solely on whether those two fields share a slot. At 2,100 gas per cold SLOAD, that layout decision made once at deployment echoes through every future interaction the protocol ever has.
Which brings up the point most developers learn too late. Storage layout is set in stone at deployment. You can upgrade proxy logic, but you cannot reshuffle an existing slot layout without a full migration. The struct you design today is the struct your users pay for indefinitely.
Group small types together. Sort by size descending within each group. Ask whether the fields you're packing actually travel through your code together. If they don't, you've built a gas leak into the foundation, and no amount of optimizer runs fixes bad pipe placement.