Gas usually shows up in a project as a task near the end. The contract works, the tests pass, and someone opens a ticket called “gas optimisation” where the plan is to shave a few percent by rewriting loops and renaming variables. That work is real but it is trivia, and it arrives far too late to matter.
The decisions that actually determine cost were made when someone chose what to keep in storage. Gas is not a tuning knob. It is a cost model with a steep, well-documented hierarchy, and like any steep cost model it should be deciding your data structures before you write them. A database engineer does not treat the memory hierarchy as an optimisation pass. Same idea, sharper gradient.
The hierarchy, in the order that matters
Exact numbers move between forks, and quoting them tends to age a piece badly, so the useful thing to internalise is the ordering and the reason behind it. The reason is always the same: whatever every node in the network has to store forever costs the most, and whatever is discarded after the transaction costs the least.
| Where the data goes | Who keeps it, and for how long | Relative cost |
|---|---|---|
| Stack and memory | Discarded when the call ends | Negligible |
| Calldata | Part of the transaction, kept in history | Low, per byte, and non-zero bytes cost more than zero bytes |
| Event log | In history, not in state. Contracts cannot read it. | Low to moderate |
| Storage read, warm | Already touched in this transaction | Moderate |
| Storage read, cold | First touch in this transaction | High |
| Storage write, new slot | Every node, forever, until cleared | Highest by a wide margin |
Two asymmetries in that table drive most real design work. Writing a slot that was previously empty costs far more than updating one that already holds a value, so the first write is the expensive one and the shape of your initialisation matters. And clearing a slot returns some of the cost, which means deleting state is cheap in a way that deleting rows in a database never is.
The event log is the archive, and it is nearly free
This is the single largest structural saving available, and it is a boundary decision rather than a trick. Ask, of every field:
Does any contract need to read this in order to decide something?
If the answer is no, it does not belong in storage. Emit it in an event and let an off-chain indexer own it. The history is just as permanent, just as verifiable, and orders of magnitude cheaper, because nodes do not have to carry it in the state they query.
Applied honestly, this cuts most first-draft contracts down hard. Human-readable names, descriptions, timestamps kept only for display, counters that exist so a front end can show a total, full histories of past actions: none of that is state. It is reporting. The rule leaves behind only what the contract must reason about, which is usually balances, ownership, authorisations, and whatever a guard clause reads.
The cost of the rule is that your product now depends on an indexer, so the indexer is production infrastructure with an on-call rotation, not a side script. That is a real trade and it should be made deliberately. It is still almost always the right one.
Slots are fixed width, so field order is a cost decision
Storage is addressed in fixed-width slots. Several small fields share one slot only if they are declared next to each other and fit. Declare them in a different order and the compiler cannot pack them, so the same data occupies more slots and every write costs more.
// Three slots. The bool cannot join either neighbour,
// because a full-width field sits between them.
struct Position {
uint128 amount; // slot 0, half used
uint256 rate; // slot 1, full
bool active; // slot 2, one byte used
}
// Two slots. amount + active + expiry now share slot 0.
struct Position {
uint128 amount; // slot 0
uint64 expiry; // slot 0
bool active; // slot 0
uint256 rate; // slot 1
}
Same fields, same semantics, one third fewer slots, and the saving repeats on every single write for the life of the contract. Note also what the second version implies: choosing uint64 for a timestamp and uint128 for an amount are not micro-optimisations, they are the decisions that make packing possible at all. Width choices are schema design here, exactly as they are in a row-oriented database.
One caution that catches people going the other way: packing several fields into one slot means a partial update has to read the slot, modify the part, and write it back. If two fields in the same slot are always written by different transactions, packing them can cost more than separating them. The question is not “how few slots”, it is “which fields change together”.
An unbounded loop is a liveness bug, not a cost problem
Any operation that iterates over a collection whose length users control has a ceiling: once the collection is large enough that the loop exceeds the block gas limit, the operation cannot be executed at all. Not slow. Impossible, permanently, with the funds or the state stuck behind it.
This is why the pattern of pushing value out to a list of recipients keeps producing frozen contracts, and why the fix is always the same shape: invert it so each participant pulls their own share, and the work per transaction is bounded by one participant rather than by the size of the crowd.
// Push: cost grows with the number of recipients, and one
// failing recipient can block everyone behind it.
function distribute() external {
for (uint i = 0; i < recipients.length; i++) {
payable(recipients[i]).transfer(share[recipients[i]]);
}
}
// Pull: constant cost, isolated failure, no shared ceiling.
function claim() external {
uint256 owed = share[msg.sender];
require(owed > 0, "nothing owed");
share[msg.sender] = 0; // clear before sending
payable(msg.sender).transfer(owed);
}
The pull version also happens to be safer for an unrelated reason: state is cleared before the external call, so a recipient that calls back into the contract finds nothing left to claim. The cost-driven design and the safety-driven design converge, which happens more often in this environment than people expect.
Mappings, arrays, and the cost of knowing the length
A mapping gives you constant-cost lookup and no way to enumerate. An array gives you enumeration and a length, and you pay for both. Teams reach for the array because enumeration feels necessary, then discover that the only consumer of the enumeration was a front end, which could have read it from events.
When you genuinely need on-chain enumeration, the honest version keeps a mapping for lookups and an array for order, accepts the double write, and deletes by swapping the last element into the hole rather than shifting. If you cannot state on-chain reason why the contract itself must iterate, use the mapping and let the indexer answer the question.
The user pays, which makes this a product question
Worth stating plainly, because it is the part that gets lost in the engineering: the cost of your data layout is not billed to you. It is billed to whoever calls the function. A layout decision made in an afternoon sets the price of using the product, on every interaction, for as long as the contract lives.
That reframes a few arguments. “We can afford one more storage write” is not a statement about your budget. Batching several actions into one call is not only a convenience feature, it materially lowers what people pay, because the expensive cold reads happen once instead of three times. And a function that is cheap for a small account and expensive for a large one has a fairness property you should know about before a large customer discovers it for you.
What to actually ask in review
- For each field in storage: which contract logic reads it? If none, why is it not an event?
- Does any function iterate over something a user can grow? What is the length at which it stops working?
- Which fields change together, and does the slot layout match that grouping?
- Are the type widths chosen for packing, or copied from a tutorial that used the default everywhere?
- Is value pushed out to recipients anywhere, and can one recipient block the rest?
- What does the most common user action cost, and did anyone measure it rather than estimate it?
None of these are optimisations. They are the questions that decide the schema, and once the schema is deployed and holding real state, they are close to unanswerable. Cost models are easiest to respect before you have written anything down.

