1. Why Gas Optimization Matters
When developing smart contracts on Ethereum, Gas is an unavoidable concept. It is neither merely a “transaction fee” nor just a temporary cost caused by network congestion. Instead, it represents a long-term constraint on contract design quality.
Many developers start paying attention to Gas only after encountering situations such as:
- Exceptionally high deployment costs
- Frequent “Out of Gas” errors when users call certain functions
- Significant cost differences between different implementations of the same functionality
These issues usually do not stem from business logic mistakes, but rather from a lack of intuition about the EVM cost model.
1.1 The Two Meanings of Gas
Before discussing optimization, it is important to distinguish two commonly confused concepts:
- gas used: the number of Gas units actually consumed when executing a transaction
- gas price: the price you are willing to pay per unit of Gas
Contract code can only affect gas used, not gas price.
This means:
- Network congestion increases gas price, but does not change a contract’s gas usage
- A poorly designed contract is expensive under any network conditions
- When gas price is high, inefficiencies in contract design become even more costly
Therefore, Gas optimization is not about “betting on network conditions,” but about minimizing the number of Gas units consumed per execution.
1.2 Why “Functionally Correct” Does Not Mean “Cost-Efficient”
In traditional software development, as long as the program works correctly, performance issues can often be optimized later. In smart contracts, however, performance is cost.
Even a contract that:
- Has no security vulnerabilities
- Implements the intended functionality correctly
- Passes all tests
can still become impractical because:
- Certain functions are too expensive to execute on-chain
- Transaction failure rates increase during peak periods
- Users pay unnecessary costs for the same functionality over time
These problems are rarely caused by “wrong code,” but by ignoring structural Gas costs at the design stage.
1.3 The Goals of Gas Optimization
Gas optimization is not about achieving the absolute lowest cost, but about serving three practical goals:
-
Reducing long-term usage costs For frequently called functions, saving even a few hundred Gas can add up significantly over time.
-
Improving transaction success rates More predictable Gas usage reduces the risk of running out of Gas in complex execution paths.
-
Improving cost predictability Making it easier for callers to estimate required Gas and reducing uncertainty.
This is why Gas optimization should usually focus on:
- High-frequency execution paths
- Core business logic
- Functions where users directly pay execution costs
1.4 When Not to Over-Optimize
It is important to recognize that Gas optimization has diminishing returns.
The following are usually not worth doing:
- Introducing complex and obscure code to save a trivial amount of Gas
- Applying heavy micro-optimizations to low-frequency or cold paths
- Sacrificing safety checks or readability for marginal gains
A reasonable principle is:
Write safe, clear, and maintainable code first, then optimize only where it truly matters.
In most cases, understanding and avoiding high-cost structures is more valuable than memorizing isolated tricks.
1.5 What Comes Next
The following sections start from the fundamentals:
- What the EVM actually charges Gas for
- Which instructions are expensive and which are almost negligible
- Why storage access is the core driver of Gas costs
Once you understand these principles, later optimization practices will feel natural rather than rule-based.
2. The Gas Cost Model
Before diving into specific optimization techniques, it is essential to build a clear cost intuition: not all EVM operations cost the same.
Many Solidity constructs that appear “simple” consume large amounts of Gas because they trigger expensive low-level instructions.
The goal of this section is simple: Know which operations should be avoided or minimized, and which can be largely ignored.
2.1 Instruction-Level Pricing
The EVM is a stack-based virtual machine. Solidity code is compiled into a sequence of EVM opcodes such as:
- Arithmetic and comparison instructions like
ADD,SUB,LT - Memory operations like
MLOAD,MSTORE - Storage operations like
SLOAD,SSTORE - External calls like
CALL,DELEGATECALL
Gas is charged entirely at the instruction level, not at the Solidity syntax level. This means:
- A single line of Solidity can compile into multiple opcodes
- Different-looking code can compile into very different instruction sequences
- Gas differences come from instruction types and counts, not from code length
In essence, Gas optimization means one thing:
Execute expensive instructions fewer times.
2.2 Cost Tiers
From a cost perspective, EVM operations can be roughly grouped (from cheapest to most expensive):
- Pure computation (arithmetic, comparisons, bitwise operations)
- Memory reads and writes
- Calldata reads
- Storage reads (
SLOAD) - Storage writes (
SSTORE) - External calls and large data returns
The key takeaway is:
Storage operations are far more expensive than most computations.
This is why so many Gas optimizations ultimately converge on the same goal: reducing storage access.
2.3 What Storage Is and Why It Is Expensive
In Solidity, all state variables are stored in storage. From the EVM’s perspective, storage is a massive key–value store:
- Key: storage slot index
- Value: 32 bytes of data
Storage has several defining characteristics:
- Data is persistent
- It affects the global state trie
- All full nodes must agree on its contents
Every storage write modifies the global state, which is the fundamental reason storage operations are so costly.
2.4 The Real Cost of Reading Storage
When you read a state variable, for example:
uint256 x = count;
the key instruction is SLOAD.
Since EIP-2929, SLOAD has two cost modes:
- Cold access: the first access to a storage slot in a transaction
- Warm access: subsequent accesses to the same slot in the same transaction
Intuitively:
- The first read “brings the value in”
- Subsequent reads are cheaper, but still far more expensive than memory or computation
Even warm SLOAD is significantly more expensive than arithmetic or memory access.
2.5 Why Writing Storage Is Among the Most Expensive Operations
When you modify a state variable, such as:
count = count + 1;
this is not a simple increment, but a full read–modify–write sequence:
SLOAD– read the old value- Perform the addition
SSTORE– write the new value
The cost of SSTORE depends on the state transition:
- Zero → non-zero: most expensive
- Non-zero → non-zero: less expensive
- Non-zero → zero: cheaper and may trigger a refund
These rules are designed to encourage contracts to free unused storage.
2.6 What a Single Line of Solidity Really Does
Consider this common statement:
count += 1;
From Solidity’s perspective, it is trivial. From the EVM’s perspective, it usually means:
- One
SLOAD - One arithmetic instruction
- One
SSTORE
If you repeat it in the same function:
count += 1;
count += 1;
count += 1;
you are not performing three additions, but triggering:
- 3
SLOADs - 3
SSTOREs
This is how Gas costs escalate quickly.
2.7 A Key Intuition
At this point, a crucial intuition should be clear:
- Arithmetic and logic are rarely the bottleneck
- Storage reads and writes dominate Gas costs
- Repeated access to the same storage slot is one of the most common and overlooked sources of waste
Once this intuition is established, later optimizations—caching, write merging, storage packing—will feel obvious rather than magical.
3. Reducing Storage Reads and Writes
After understanding the EVM cost model, the priority of Gas optimization becomes very clear:
Any reduction in storage access almost certainly leads to significant Gas savings.
Most Gas optimization techniques revolve around a single core objective:
Make
SLOADandSSTOREexecute as few times as possible.
3.1 Why Storage Optimization Has the Highest ROI
As discussed earlier:
- Arithmetic operations are very cheap
- Memory operations are moderately priced
- Storage writes are among the most expensive operations
This implies:
- Eliminating a single
SSTOREis often more valuable than optimizing dozens of arithmetic instructions - Optimizing storage access typically yields order-of-magnitude improvements
In practice, Gas optimization should follow this order:
- First, reduce storage reads and writes
- Then consider secondary optimizations such as loops, parameters, and bit-level tricks
3.2 Caching Repeated Reads: Turning Multiple SLOADs into One
One of the most common and easily overlooked inefficiencies is reading the same state variable multiple times within a function.
For example:
function increment() external {
require(count < max, "too large");
count = count + 1;
emit Updated(count);
}
In this code, count is actually read multiple times:
- Once in the
require - Once during the increment
- Once again when emitting the event
A more efficient version caches it in a local variable:
function increment() external {
uint256 c = count;
require(c < max, "too large");
c = c + 1;
count = c;
emit Updated(c);
}
The result is:
- Storage is read only once
- Storage is written only once
- All intermediate operations happen in memory
This change barely affects readability, yet can significantly reduce Gas usage.
3.3 Merging Writes: Avoiding Repeated SSTOREs
Another common issue is writing to the same storage variable multiple times within a single function.
For example:
function update(uint256 x) external {
value += x;
if (x > threshold) {
value += bonus;
}
}
Although the logic is clear, this code may trigger multiple SSTOREs on value.
A better approach is to perform all calculations in memory first:
function update(uint256 x) external {
uint256 v = value;
v += x;
if (x > threshold) {
v += bonus;
}
value = v;
}
The guiding principle is:
Do not repeatedly write to storage inside conditional logic—compute first, then write back once.
3.4 Storage Pitfalls Inside Loops
Loops are where storage inefficiencies are most easily amplified.
Consider the following implementation:
function sum(uint256[] calldata arr) external {
for (uint256 i = 0; i < arr.length; i++) {
total += arr[i];
}
}
In this loop:
totalis read and written on every iteration- For an array of length
n, this triggersnSLOADs andnSSTOREs
A more efficient version is:
function sum(uint256[] calldata arr) external {
uint256 t = total;
uint256 len = arr.length;
for (uint256 i = 0; i < len; i++) {
t += arr[i];
}
total = t;
}
This small change has an impact that grows linearly with the array length.
3.5 Design Principles for Avoiding Storage Writes in Loops
When designing contracts, try to avoid patterns like:
- Writing to storage inside unbounded loops
- Updating state on every iteration
- Letting loop bounds be fully controlled by external input
Better alternatives include:
- Computing results in memory and committing once
- Designing batch interfaces with strict per-call limits
- Moving complex computation off-chain and only verifying results on-chain
These are not mere “coding tricks,” but structural design decisions that should be made early.
3.6 Using Mappings Instead of Arrays
In contracts, the choice between arrays and mappings is not just a style preference—it reflects a fundamental cost model difference:
- Array operations like search, deduplication, or deletion often require traversal, with O(n) cost and unbounded loops
- Mapping access is direct by key, close to O(1), making it more predictable and Gas-efficient
Case 1: Membership Checks (contains)
Not recommended: storing members in an array and searching on-chain
address[] public members;
function isMember(address a) public view returns (bool) {
for (uint256 i = 0; i < members.length; i++) {
if (members[i] == a) return true;
}
return false;
}
Problems:
- Requires traversal on every check
- Cost grows with the number of members
- Worst case can run out of Gas
Recommended: use a mapping for existence checks
mapping(address => bool) public isMember;
function addMember(address a) external {
isMember[a] = true;
}
function removeMember(address a) external {
isMember[a] = false;
}
Advantages:
- O(1) existence checks
- Predictable cost
- No loops required
Case 2: Enumerable Sets (O(1) checks + enumeration)
Some applications require both:
- O(1) membership checks
- Enumeration of all members (e.g., for frontends)
A common pattern is mapping + array:
mapping(address => bool) public isMember;
address[] public memberList;
mapping(address => uint256) private indexPlusOne; // index + 1, 0 means absent
function addMember(address a) external {
if (indexPlusOne[a] != 0) return;
isMember[a] = true;
memberList.push(a);
indexPlusOne[a] = memberList.length;
}
function removeMember(address a) external {
uint256 idxPlusOne = indexPlusOne[a];
if (idxPlusOne == 0) return;
uint256 idx = idxPlusOne - 1;
uint256 last = memberList.length - 1;
if (idx != last) {
address lastAddr = memberList[last];
memberList[idx] = lastAddr;
indexPlusOne[lastAddr] = idx + 1;
}
memberList.pop();
indexPlusOne[a] = 0;
isMember[a] = false;
}
Explanation:
- The mapping handles O(1) checks and indexing
- The array handles enumeration
- Deletion uses swap-and-pop to avoid O(n) shifts
Note:
- This introduces extra storage (index mapping)
- In exchange, operation cost becomes predictable and bounded—usually well worth it
When Arrays Are the Wrong Choice
If you find yourself doing any of the following with arrays, consider switching to mappings:
- Membership checks
- Deduplication
- Deleting arbitrary elements
- Preventing duplicate inserts
- Any traversal driven by unbounded user input
In short:
Arrays are for ordered data and index-based access. Mappings are for lookup, deduplication, and existence checks. When searching or deleting is involved, mappings are usually cheaper and safer.
4. Data Location and Function Interface Design
After reducing unnecessary storage access, the next category of high–value optimizations lies in function interface design, especially:
- Parameter data locations
- Function visibility
These choices usually do not affect business logic, but they directly influence:
- Whether unnecessary data copying occurs
- Whether extra ABI encoding/decoding is triggered
- Which execution path the EVM takes
Well-designed interfaces often provide low-risk, high-return Gas optimizations.
4.1 Cost Intuition for Data Locations
In Solidity, reference types (arrays, structs, strings, bytes) must explicitly or implicitly specify a data location:
- calldata: read-only, located in call data
- memory: read–write, exists during function execution
- storage: persistent, stored on-chain
From a cost perspective, a simple intuition applies:
- Reading calldata: cheap
- Reading/writing memory: moderate
- Reading/writing storage: expensive
A fundamental rule follows:
Prefer calldata over memory, and memory over storage whenever possible.
4.2 external Functions and calldata
For functions that are only called externally, the recommended pattern is:
function process(uint256[] calldata data) external {
// use data
}
Reasons:
- Parameters of
externalfunctions naturally live in calldata - No need to copy data into memory
- For large arrays or complex structs, the savings can be substantial
By contrast:
function process(uint256[] memory data) public {
// use data
}
Even if data is not modified, the compiler must:
- Copy the entire calldata payload into memory
- Pay the additional Gas cost for that copy
4.3 When memory Is Required
The limitation of calldata is clear: it is read-only.
If a function needs to:
- Modify array contents
- Sort
- Deduplicate
- Dynamically construct new arrays
then memory is required.
Example:
function normalize(uint256[] calldata data)
external
returns (uint256[] memory)
{
uint256[] memory result = new uint256[](data.length);
for (uint256 i = 0; i < data.length; i++) {
result[i] = data[i] / 2;
}
return result;
}
Key points:
- Input parameters use calldata to avoid copying
- Output uses memory because it must be writable
This is a very common and sensible pattern.
4.4 Gas Implications of Function Visibility
Function visibility affects not only accessibility, but also Gas cost.
A useful intuition:
- external: reads parameters directly from calldata, cheapest
- public: parameters are copied into memory, more expensive
- internal: often inlined or called via jump, very cheap
- private: similar to internal, but limited to the contract
An important conclusion:
publicis not a universally optimal “inside-and-outside” choice.
4.5 External Entry + Internal Implementation
A highly recommended pattern in real projects is:
- Expose
externalfunctions - Move core logic into
internalfunctions
Example:
function update(uint256 x) external {
_update(x);
}
function _update(uint256 x) internal {
// core logic
}
Benefits:
- External calls use calldata with minimal copying
- Internal calls are extremely cheap
- One shared implementation for both internal and external usage
This pattern has almost no downsides and avoids many hidden Gas costs.
4.6 Why You Should Avoid Calling this
A subtle but expensive anti-pattern is:
this.update(x);
Even if update is defined in the same contract, this triggers:
- A full external call
- ABI encoding and decoding
- A
CALLopcode execution
Consequences:
- Higher Gas cost
- More complex execution path
- Potential reentrancy risks
If you find yourself using this.foo(), it usually means:
- Logic is poorly structured
- Internal abstraction is insufficient
The correct refactor is to extract logic into an internal function and call it directly.
4.7 A Comparison Example
Less efficient approach:
function foo(uint256[] memory data) public {
// use data
}
function bar(uint256[] memory data) public {
foo(data);
}
Recommended approach:
function foo(uint256[] calldata data) external {
_foo(data);
}
function bar(uint256[] calldata data) external {
_foo(data);
}
function _foo(uint256[] calldata data) internal {
// use data
}
Why the second approach is better:
- Avoids repeated parameter copying
- Centralizes logic
- Uses cheaper internal calls
5. State Layout Design: Storage Packing
So far, most optimizations have focused on how state variables are accessed. This section addresses a more structural question:
How state variables are laid out in storage.
A significant amount of Gas waste comes not from frequent access, but from inefficient state layout, leading to:
- More storage slots than necessary
- Extra
SLOAD/SSTOREoperations - Amplified long-term costs
5.1 Storage Slots and 32-Byte Alignment
From the EVM’s perspective, storage is organized in 32-byte (256-bit) slots.
Solidity places state variables into slots in declaration order:
- Variables smaller than 32 bytes may share a slot
- If remaining space is insufficient, a new slot is used
- Once a slot is full, no more variables are packed into it
This mechanism is known as storage packing.
5.2 A Basic Packing Example
Consider:
uint128 a;
uint128 b;
uint256 c;
Each uint128 uses 16 bytes, so a and b share one slot.
c occupies its own slot.
Total: 2 slots.
Now change the order:
uint128 a;
uint256 c;
uint128 b;
Result:
auses the first 16 bytes of slot 0coccupies slot 1 entirelybcannot fit in slot 0 and goes to slot 2
Total: 3 slots—one extra slot used purely due to ordering.
5.3 Why Slot Count Directly Affects Gas
Every additional storage slot increases long-term cost:
- More slots read → more
SLOADs - More slots written → more
SSTOREs - Higher cost when copying or updating structs
This is especially important in:
- High-frequency functions
- Struct-heavy designs
- Long-lived contracts
5.4 Smaller Types Are Not Always Cheaper
Important caveat:
- Using types smaller than 256 bits does not automatically save Gas
- Savings only occur if packing actually happens
Example:
uint128 a;
uint256 b;
Even though a is smaller, it still occupies its own slot because b follows it.
Type choice and declaration order must be considered together.
5.5 When Storage Packing Is Worth the Effort
Not every contract needs aggressive packing.
Storage packing is most valuable when:
- There are many state variables
- Structs are used extensively
- State is frequently read and written
- The contract has a long operational lifetime
For small, rarely-used contracts, the benefit may be marginal.
6. Loops and Batch Processing
From the previous sections, one pattern should already be clear:
- Storage access is expensive
- Loops amplify that cost linearly
Loops themselves are not inherently bad, but unbounded or poorly designed loops almost always become a Gas problem.
6.1 Why Loops Easily Become Gas Black Holes
From the EVM’s perspective, a loop is nothing special—it simply:
- Repeats a sequence of instructions
- Pays the full instruction cost on every iteration
If the loop body contains:
- Storage reads or writes
- Expensive computations
- External calls
then Gas usage grows linearly with the iteration count.
The risk becomes severe when the loop length is controlled by external input.
6.2 Caching Array Length and Intermediate Results
A very common and basic optimization is caching an array’s length.
For example:
function sum(uint256[] calldata arr) external {
uint256 s = 0;
for (uint256 i = 0; i < arr.length; i++) {
s += arr[i];
}
}
Although arr.length looks trivial, it is re-read on every loop condition check.
A better version is:
function sum(uint256[] calldata arr) external {
uint256 s = 0;
uint256 len = arr.length;
for (uint256 i = 0; i < len; i++) {
s += arr[i];
}
}
This optimization saves little Gas per iteration, but the difference becomes noticeable in large arrays or high-frequency calls.
6.3 Avoid Writing to Storage Inside Loops
As emphasized earlier, writing to storage inside loops is extremely expensive.
The principle can be summarized as:
Do calculations inside loops, but commit results to storage only once, outside the loop.
6.4 Using unchecked Safely
Since Solidity 0.8, integer arithmetic includes overflow checks by default. This is valuable for safety, but it also introduces extra Gas cost.
A typical example is a loop counter:
for (uint256 i = 0; i < len; i++) {
// ...
}
If you can guarantee that:
iwill never approachtype(uint256).max- The loop has a strict upper bound
then you can use:
for (uint256 i = 0; i < len; ) {
// ...
unchecked {
i++;
}
}
The Gas savings are smaller than storage optimizations, but still measurable in large loops.
6.5 Avoid Unbounded Loops
When designing contract interfaces, try to avoid:
- Loop bounds fully controlled by user input
- Loops without explicit upper limits
For example:
function process(uint256[] calldata items) external {
for (uint256 i = 0; i < items.length; i++) {
// ...
}
}
If items.length is unrestricted, callers can pass extremely large arrays, causing:
- Transaction failure due to Out of Gas
- Practical unavailability of the function
Common improvements include:
- Enforcing a maximum length
- Splitting work across multiple transactions
- Providing paginated or cursor-based APIs
6.6 Trade-offs in Batch Design
Batch processing is often used to reduce transaction count and amortize fixed costs, but it is not free.
Advantages:
- Fewer external calls
- Amortized function entry and validation cost
Risks:
- Unpredictable per-transaction Gas usage
- Higher likelihood of Out of Gas
- Harder Gas estimation
Therefore, batch interfaces should usually provide:
- A clear per-call upper bound
- Predictable worst-case cost
- Well-defined failure behavior
6.7 A Batch Processing Example
Not recommended:
function batchUpdate(uint256[] calldata ids) external {
for (uint256 i = 0; i < ids.length; i++) {
update(ids[i]);
}
}
Improved version:
uint256 constant MAX_BATCH = 100;
function batchUpdate(uint256[] calldata ids) external {
uint256 len = ids.length;
require(len <= MAX_BATCH, "too many items");
for (uint256 i = 0; i < len; ) {
_update(ids[i]);
unchecked {
i++;
}
}
}
The key improvement is not raw Gas savings, but:
- Controlled cost
- Predictable behavior
- A more user-friendly interface
7. Error Handling and Bytecode Size Optimization
Gas optimization discussions often focus on the “successful execution path,” while overlooking failure paths and contract bytecode size.
In reality, error handling affects not only revert-time Gas usage, but also:
- Deployment cost
- Baseline execution cost
- Bytecode size and maintainability
This section focuses on a practical and consistently beneficial optimization: efficient error handling and reverts.
7.1 Reverts Are Not Free
When a transaction reverts, state changes are rolled back—but Gas is not fully refunded.
Costs that still apply include:
- Gas consumed by executed instructions
- Data returned with the revert
- ABI encoding overhead
Therefore, frequently triggered validation logic deserves careful cost consideration, even on failure paths.
7.2 The Real Cost of require(string)
The traditional error-handling pattern is:
require(msg.sender == owner, "Not owner");
The problem is not correctness, but cost:
- The string literal is embedded in bytecode
- Each revert returns the string data
- Longer strings increase deployment and revert costs
In large contracts, extensive use of require(string) significantly increases bytecode size.
7.3 Motivation for Custom Errors
Solidity 0.8.4 introduced custom errors:
error NotOwner();
Used as:
if (msg.sender != owner) revert NotOwner();
Advantages:
- No embedded strings
- Error identifiers encoded as selectors
- Much smaller revert payloads
From a cost perspective, this reduces:
- Deployment Gas
- Revert-path Gas
7.4 Comparison Example
Using require(string):
function withdraw() external {
require(msg.sender == owner, "Not owner");
// ...
}
Using a custom error:
error NotOwner();
function withdraw() external {
if (msg.sender != owner) revert NotOwner();
// ...
}
Gas usage on the success path is nearly identical, but differences appear in:
- Contract size
- Revert Gas cost
- Error encoding efficiency
These differences accumulate in complex or high-frequency contracts.
7.5 How Detailed Should Error Messages Be?
A common misconception is:
More detailed error messages are always better.
From an on-chain perspective, this is often untrue.
A better division of responsibility is:
- On-chain: concise, structured error identifiers
- Off-chain: documentation or mapping tables explaining errors
Custom errors fit this model well because they are:
- Structured
- Parameterizable
- Easy for frontends and SDKs to decode
Example:
error InsufficientBalance(uint256 available, uint256 required);
7.6 Why Bytecode Size Matters
Contract bytecode size directly affects:
- Deployment cost
- Deployment success (there is a size limit)
- Baseline execution cost (larger code costs more to load)
The following increase bytecode size:
- Large numbers of string literals
- Repeated logic branches
- Verbose error messages
Gas optimization is therefore not only about runtime execution, but also deployment-time efficiency.
7.7 Balancing Error Handling and Readability
It is important to stress:
- Custom errors are not about extreme compression
- They do not require sacrificing readability
A reasonable approach is:
- Use custom errors for public-facing core interfaces
- Use
requireselectively for internal assertions or development checks - Avoid embedding long textual descriptions in bytecode
8. Event and Return Value Design
In smart contracts, events and function return values are commonly used to expose information externally. However, if designed poorly, they can easily become hidden sources of Gas consumption, especially in high-frequency or data-heavy scenarios.
The core idea of this section can be summarized as:
Blockchains are good at state verification, not data querying.
Understanding this helps you make more economical design choices for events and return values.
8.1 The Role Boundary of Events
The primary purposes of events are:
- Allowing off-chain systems to listen and index
- Recording important state changes
- Supporting auditing and analysis
Events are not readable on-chain and do not affect subsequent execution logic. From an execution perspective, events are “write once, off-chain only” data.
This leads to a key design principle:
Events should serve off-chain consumers, not replace on-chain state queries.
8.2 Gas Cost Composition of Events
The Gas cost of an event consists of two parts:
-
Topics
- The event signature
- Up to three
indexedparameters
-
Data
- Non-indexed parameters
- Charged by byte size
Intuitively:
indexedparameters improve filterability- But they are not free
- Larger data payloads cost more Gas
Event design therefore requires a trade-off between queryability and cost.
8.3 Proper Use of indexed
Consider a typical transfer event:
event Transfer(address indexed from, address indexed to, uint256 amount);
This design makes sense because:
fromandtoare common filter fieldsamountis rarely used as a filter
But if written as:
event Transfer(
address indexed from,
address indexed to,
uint256 indexed amount
);
Then:
- Query power barely improves
- Gas cost increases
- You quickly hit the three-index limit
A practical rule of thumb:
Only mark fields as
indexedif they are frequently used for filtering.
8.4 Avoid Carrying Large Data in Events
A common but expensive pattern is emitting large data structures:
event DataUpdated(uint256[] values);
Problems:
- Entire arrays are written to logs
- Gas cost grows linearly with data size
- Both on-chain execution and off-chain storage become expensive
Better alternatives include:
- Emitting only identifiers, hashes, or counters
- Storing full data off-chain
- Linking via hashes or IDs
Example:
event DataUpdated(bytes32 dataHash);
8.5 Avoid Returning Large Arrays On-Chain
Solidity allows functions to return arrays or structs:
function getUsers() external view returns (User[] memory);
While intuitive, this design is not cheap.
What Happens in On-Chain Calls
If this function is called by another contract, even as a view function, it still consumes Gas.
The EVM must:
- Read all
Userentries from storage - Copy them into memory
- ABI-encode the entire array
- Return the encoded bytes
All of these costs scale linearly with array size—with no upper bound.
Why This Is Often Unnecessary
In real-world projects, full data lists are usually:
- Needed by frontends or backend services
- Used for display, analytics, or reporting
- Not relied upon by other contracts
Off-chain systems can retrieve such data for free using eth_call.
This creates a common inefficiency:
- On-chain callers pay Gas for data
- The actual consumers are off-chain
- Off-chain consumers could have read the data at zero cost
A Better Design Approach
Function return values should distinguish between:
-
On-chain callable functions Keep return values minimal or return nothing
-
Off-chain query functions Returning arrays or structs is acceptable, but intended for
eth_callonly
If on-chain access is required, pagination or cursor-based APIs are safer.
8.6 Pagination and Cursor-Based Access
For large datasets, pagination is usually preferable:
function getUsers(uint256 offset, uint256 limit)
external
view
returns (User[] memory)
{
// return a slice
}
Benefits:
- Bounded Gas usage for on-chain calls
- Efficient off-chain iteration
- Predictable interface behavior
8.7 Event Design Comparison Example
Not recommended:
event OrderCreated(
address user,
uint256[] itemIds,
uint256[] prices
);
Improved version:
event OrderCreated(
address indexed user,
bytes32 orderId
);
With off-chain systems:
- Resolving full order data via
orderId - Treating events as index signals, not data containers
This design is far more scalable and cost-effective.
9. Compact Representation and Low-Level Optimizations (Use with Caution)
So far, most optimizations discussed share common traits:
- No major readability loss
- Controlled risk
- Stable, predictable gains
This section covers advanced techniques that can save Gas, but also introduce:
- Reduced readability
- Higher implementation complexity
- Increased audit and maintenance costs
The goal here is not to advocate their use, but to answer:
Which low-level optimizations are worth using, and under what conditions.
9.1 Bit Operations and Bitmaps
A common and relatively safe advanced technique is the bitmap.
Suppose you need to track many boolean states:
- Whether an address completed a step
- Whether an ID is used
- A fixed-size set of flags
The straightforward approach:
mapping(uint256 => bool) used;
This is clear, but each bool consumes an entire storage slot.
Bitmap Approach
If the keys are:
- Sequential
- Bounded
- Numerous
you can pack 256 boolean values into one uint256:
uint256 bitmap;
Example:
function isUsed(uint256 index) internal view returns (bool) {
return (bitmap & (1 << index)) != 0;
}
function setUsed(uint256 index) internal {
bitmap |= (1 << index);
}
Benefits:
- One slot stores 256 flags
- Far fewer storage reads/writes
- Significant Gas savings in high-frequency scenarios
9.2 When Bitmaps Make Sense
Bitmaps are suitable when:
- The maximum number of states is known
- Indices are controlled and not arbitrary user input
- Logic is stable and unlikely to change
They are unsuitable when:
- Keys are addresses or hashes
- State count is unbounded
- Readability and flexibility are critical
A useful heuristic:
If you need documentation just to explain what each bit means, complexity has already increased significantly.
9.3 Compact Encoding and “Slot-Saving” Thinking
Some projects go further by:
- Packing multiple fields into a single
uint256 - Using bit shifts and masks manually
- Re-implementing storage packing logic
Example:
uint256 packed;
Where:
- Upper 128 bits store balance
- Lower 128 bits store timestamp
While this can reduce slot count, it also:
- Requires bit manipulation on every access
- Increases risk of boundary bugs
- Makes auditing and debugging harder
Such techniques are justified only when:
- Data structures are extremely stable
- Access frequency is very high
- Slot count is confirmed as the main bottleneck
9.4 About assembly
Solidity allows inline assembly, enabling direct EVM opcode usage:
- Skipping compiler-generated overhead
- Achieving lower Gas in extreme cases
But be cautious:
- No type checks
- No overflow protection
- Dramatically reduced readability
In most business contracts, the risk outweighs the benefit.
9.5 When assembly Is Worth Using
Reasonable scenarios include:
- Extremely hot execution paths
- Low-level utility functions
- Logic that is stable and well-tested
- Teams with audit support and experience
Avoid using assembly for:
- Core business logic
- Permission or fund management
- Marginal Gas savings
A conservative rule:
If Gas is already within a reasonable range without assembly, do not use assembly.
9.6 Evaluating the Real Gains of Advanced Optimizations
Advanced optimizations usually yield:
- Tens to hundreds of Gas saved per call
- Meaningful impact only in high-frequency paths
Before using them, you should already have:
- Completed storage, interface, and loop optimizations
- Identified real bottlenecks
- Measured Gas with actual test data
Otherwise, it is easy to fall into “optimization for its own sake.”
10. How to Verify Whether an Optimization Is Effective
After discussing many techniques, a more important question emerges:
How do you know an optimization is actually worth it?
This section emphasizes a data-driven approach rather than intuition.
10.1 Why You Cannot Rely on Intuition
Gas cost does not always correlate with perceived code complexity:
- Some complex refactors barely change Gas
- Some small changes save a lot
- Some optimizations matter only at scale
Without measurement, it is easy to:
- Miss high-impact improvements
- Over-optimize low-impact areas
Gas optimization must therefore be measured engineering, not guesswork.
10.2 What Makes an Optimization “Effective”
An optimization should answer three questions:
- How much Gas does it save?
- How frequently does this code path execute?
- How much complexity or risk does it introduce?
Only when savings justify complexity is the optimization worthwhile.
10.3 Basic Benchmarking Approach
The simplest and most reliable method is before/after comparison:
- Same input
- Only one implementation change
- Compare
gas used, not transaction fees
Example:
- Original function A
- Optimized function A′
- Measure Gas under identical conditions
10.4 A Conceptual Comparison Example
Original:
function add(uint256[] calldata xs) external {
for (uint256 i = 0; i < xs.length; i++) {
total += xs[i];
}
}
Optimized:
function addOptimized(uint256[] calldata xs) external {
uint256 t = total;
uint256 len = xs.length;
for (uint256 i = 0; i < len; ) {
t += xs[i];
unchecked { i++; }
}
total = t;
}
The real question is:
- At
xs.length = 10,100,1000 - Do Gas curves diverge meaningfully?
That divergence is what matters.
10.5 Focus on Worst-Case, Not Just Averages
In smart contracts, worst-case behavior often matters more:
- Insufficient Gas causes failure
- Users hit edge cases
- Batch and loop risks concentrate at extremes
Testing should therefore:
- Use maximum allowed inputs
- Cover boundary conditions
- Check proximity to block or function Gas limits
10.6 Tools Matter Less Than Methodology
Teams may use:
- Hardhat
- Foundry
- Truffle
- Custom scripts
The methodology is always the same:
- Fix inputs
- Repeat tests
- Compare Gas usage
- Let data drive decisions
10.7 When to Stop Optimizing
A frequently overlooked question is: when is enough enough?
Stop when:
- Further savings are marginal
- Code complexity increases noticeably
- High-frequency and high-cost paths are already optimized
- Audit and maintenance costs outweigh benefits
Gas optimization is a balance, not an endless pursuit.
11. An Actionable Gas Optimization Checklist
After systematically covering Gas optimization principles, we can now condense them into a practical checklist for daily development and code reviews.
This is not a rigid rulebook, but a priority-driven review order.
11.1 Design-Phase Checks
Before writing code, consider:
- Is this state truly necessary, or derivable?
- Will the state be frequently accessed?
- Are there unbounded loops or batch interfaces?
- Does the data structure have a clear size limit?
- Are lookup, deduplication, or deletion required?
Many Gas issues can be avoided at this stage.
11.2 Storage Checks (Highest Priority)
- Are storage variables read multiple times in one function?
- Is storage written inside loops?
- Can caching reduce
SLOAD/SSTORE? - Are variable and struct field orders optimized for packing?
- Is unused state deleted?
- Are mappings used instead of arrays for lookup and deduplication?
- If enumeration is needed, is mapping + array + index used with swap-and-pop?
This is where optimization effort pays off the most.
11.3 Function Interface and Parameter Checks
- Are external interfaces marked
external? - Do external functions use
calldata? - Are unnecessary
publicfunctions avoided? - Are
thiscalls avoided? - Is the external-entry + internal-implementation pattern used?
These optimizations are low-risk and consistently effective.
11.4 Loop and Batch Checks
- Are array lengths and intermediate values cached?
- Is storage avoided inside loops?
- Are O(n) lookups avoided in loops?
- Is
uncheckedused safely where applicable? - Are loop bounds explicit?
- Do batch interfaces limit per-call size?
Loops amplify Gas—always review them from a worst-case perspective.
11.5 Error Handling and Bytecode Size
- Are custom errors used instead of
require(string)? - Are error identifiers concise and structured?
- Are long strings avoided in bytecode?
- Is contract size close to deployment limits?
These optimizations become more valuable as contracts grow.
11.6 Events and Return Values
- Do events only log essential information?
- Are
indexedfields used only for frequent filters? - Are large arrays or strings avoided in events?
- Are large on-chain return values avoided?
- Are pagination or off-chain indexing used instead?
The goal: do not treat the chain as a database.
11.7 Advanced Optimizations (Use Sparingly)
- Are foundational optimizations already complete?
- Is the bottleneck clearly identified?
- Do bitmaps or packing actually reduce slot usage?
- Is assembly avoided in core business logic?
Advanced optimizations should be data-backed exceptions, not defaults.
11.8 Let Data Drive Final Decisions
Before merging any optimization:
- Is there clear Gas comparison data?
- Is the optimized path high-frequency or critical?
- Is the added complexity testable and auditable?
If the benefit cannot be clearly explained, the optimization is probably unnecessary.