The Slot Is the Unit of Billing

You're auditing a liquidation function. It reads three fields from a struct, the function is simple, the logic is tight, and yet the gas estimate looks wrong by about 2,100 units. You stare at the code. Nothing jumps out. Then you scroll up to the struct declaration, count the field widths, and there it is: somebody put a `uint256` in the middle and blew the packing.

Same data. Same function. The difference is pure layout.

That's not a quirk. It's the entire logic of the EVM's storage model, and once you see it clearly, you start reading other people's contracts very differently.

Ethereum's state lives in a key-value store. Every contract gets its own namespace, and the keys are 32-byte slots. Each slot holds 32 bytes of data. The billing unit is the slot: reading a slot you haven't touched in the current transaction costs 2,100 gas (a "cold" SLOAD, as codified in EIP-2929). Reading it a second time in the same transaction drops to 100 gas ("warm"). Writing to a cold slot for the first time costs 20,000 gas. Those numbers don't care how much of the slot you actually use.

Solidity knows this. Its compiler packs consecutive state variables into the same 32-byte slot wherever they fit, right to left, in declaration order. A `uint128` takes 16 bytes. Two `uint128`s declared back-to-back fit perfectly into one slot. Two `uint128`s with a `uint256` between them each get their own slot, because the 256-bit value exhausts the space in between.

Struct fields follow the same rule, in the order you wrote them.

How Two Identical Structs Can Cost Twice as Much

Consider two developers, Priya and Marcus, both writing a lending protocol. Both define a position struct with the same four fields: a `uint128` principal, a `uint128` interest, a `uint256` collateral, and a `uint128` lastUpdated.

Priya writes them in that exact order. Marcus, working from an older template, puts `collateral` second:

```solidity // Priya's layout struct Position { uint128 principal; // slot 0, bytes 0-15 uint128 interest; // slot 0, bytes 16-31 uint256 collateral; // slot 1 uint128 lastUpdated; // slot 2 }

// Marcus's layout struct Position { uint128 principal; // slot 0, bytes 0-15 uint256 collateral; // slot 1 (uint128 interest couldn't share with uint256) uint128 interest; // slot 2 uint128 lastUpdated; // slot 2, bytes 16-31 } ```

A liquidation function needs to read `principal`, `interest`, and `lastUpdated`. In Priya's contract, that's two cold SLOADs: slot 0 (gets both `principal` and `interest` in one read) and slot 2. Total: 4,200 gas for the storage reads. In Marcus's contract, those three fields live in slots 0, 2, and 2 respectively, so it's also two SLOADs. Actually identical here.

Now add a function that reads all four fields. Priya: three slots (0, 1, 2), three cold SLOADs, 6,300 gas. Marcus: also three slots. Still the same.

The divergence appears when the function only needs `interest` and `lastUpdated`. In Priya's layout, `interest` is in slot 0 and `lastUpdated` is alone in slot 2: two SLOADs. In Marcus's layout, `interest` and `lastUpdated` share slot 2. One cold SLOAD. Marcus wins that particular read by 2,100 gas.

The lesson isn't that one layout is universally better. It's that the access pattern of your hottest functions should drive the packing order. Pack the fields that are read together into the same slot. This is not a style preference; it's a design decision with a real cost attached.

The Warm/Cold Boundary Is Where It Gets Interesting

A single transaction can touch the same slot many times, and only the first touch is expensive. This creates a subtle trap in loops.

Imagine a function iterating over 50 positions and reading `principal` from each. If each position is a separate struct instance in a mapping, each is a distinct slot, each costs 2,100 gas cold. That's 105,000 gas just for the reads, before any logic runs. Caching the value in a local variable after the first read doesn't help when the next iteration hits a different storage slot entirely. The warm benefit is per slot address, not per field name.

This is why gas-optimized contracts often copy an entire struct to memory at the start of a function:

```solidity Position memory pos = positions[user]; ```

One line. All necessary SLOADs fire upfront, every relevant slot goes warm, and the rest of the function reads from memory at 3 gas each. Think of it like prefetching: pull the pipe pressure once, then work freely downstream. If you're reading four fields across three slots, you pay 6,300 gas once and then operate freely. Read those fields piecemeal across multiple storage accesses and you risk re-paying cold costs if the compiler doesn't optimize them away.

The warm/cold accounting applies within a slot across a transaction, not across different slots. If your function reads slot 0 of a struct, does some computation, and reads slot 0 again, the second read is 100 gas. But slot 0 of a different struct instance is another 2,100 gas cold. Each unique slot address gets its own ledger.

The Part About Structs Inside Mappings

A mapping in Solidity computes its slot addresses with `keccak256(key . baseSlot)`. Each key gets a completely separate region of storage. The struct fields then pack sequentially from that computed base address.

So `positions[userA].principal` and `positions[userB].principal` are in entirely different slots, even though they're the same field of the same struct type. No sharing, no adjacency benefit across keys. The packing rules only help you within a single struct instance.

This also means you can't predict slot addresses for mappings at compile time, which matters if you're writing contracts that interact with other contracts' storage directly (a pattern seen in proxy upgrade systems and some DeFi protocols). The `keccak256` derivation is deterministic but not sequential, so adjacent-looking fields in a mapping are scattered across the 2^256 address space in practice.

Ask yourself: when you last reviewed a struct in production code, did you check the slot count? Found an inefficient layout in a contract you're auditing and you're seeing three slots used where two would do, the developer almost certainly declared fields by logic rather than by alignment. It's one of the most common and most fixable gas inefficiencies in production Solidity.

The slot packing rules are deterministic, public, and fully exploitable in your favor. The EVM bills you for the slot, not the bytes inside it. Bad field order is a tax you keep paying on every call, forever, for the life of the contract. Pack accordingly.