Skip to content

Gas Optimization Techniques in Solidity: Comprehensive Guide with Code Examples 2025

Gas optimization techniques in Solidity

Smart contract development on Ethereum requires a delicate balance between functionality and efficiency. Gas optimization is crucial not just for cost reduction, but also for ensuring your contracts remain viable during periods of network congestion. This article explores advanced gas optimization techniques that can significantly reduce the operational costs of your smart contracts.

Understanding Gas Mechanics

Every single operation in EVM consumes a certain amount of gas. The price of gas is not established arbitrarily but is done to have an idea of the costs of each operation with respect to computational and storage resources. Understanding these charges is key to writing efficient smart contracts.

The EVM offers different pricing for various operations. In general, the most expensive operations involve storage, where SSTORE costs 20,000 gas for setting up and 5,000 gas for updates.

  • Memory operations are relatively cheap but scale quadratically with size
  • Computational operations like addition, 3 gas and multiplication, 5 gas are the cheap operations
  • External calls have a base cost of 700 gas plus additional data transfer costs

Basic Optimization Techniques

Storage Optimization

Storage is the most expensive resource in Ethereum. Each storage slot (256 bits) costs 20,000 gas to initialize and 5,000 gas to update. By optimizing how we use storage, we can significantly reduce transaction costs. The key is to minimize storage operations and maximize storage slot usage. Below is an inefficient versus optimized example of storage usage:

// Inefficient Implementation
contract Inefficient {
    uint256[] public values;
    
    function processValues(uint256[] memory newValues) public {
        for (uint256 i = 0; i < newValues.length; i++) {
            values.push(newValues[i]); // Each iteration performs a storage write
        }
    }
}

// Optimized Implementation
contract Optimized {
    uint256[] public values;
    
    function processValues(uint256[] memory newValues) public {
        uint256[] storage cachedValues = values;
        uint256 originalLength = cachedValues.length;
        cachedValues.length = originalLength + newValues.length;
        
        for (uint256 i = 0; i < newValues.length; i++) {
            cachedValues[originalLength + i] = newValues[i];
        }
    }
}

Memory vs Calldata

The choice between memory and calldata for function parameters can significantly impact gas costs. Call data is a read-only area where function arguments are stored, while memory is a temporary storage area that can be modified. Using call data for read-only array arguments saves gas by avoiding unnecessary data copying. Thus for functions that receive arrays or strings as parameters, using calldata instead of memory can save significant gas:

// Expensive: Copies array to memory
function processArray(uint256[] memory data) external {
    // Process array
}

// Cheaper: Uses calldata directly
function processArray(uint256[] calldata data) external {
    // Process array
}

Variable Packing

The EVM operates with 32-byte storage slots. Multiple smaller variables can be packed into a single slot, reducing storage costs. This is particularly effective for variables that are frequently updated together.

// Inefficient: Uses 3 storage slots
contract Inefficient {
    uint256 a; // 32 bytes
    uint8 b;   // 1 byte
    uint8 c;   // 1 byte
}

// Optimized: Uses 2 storage slots
contract Optimized {
    uint256 a; // 32 bytes
    uint8 b;   // 1 byte, packed with c
    uint8 c;   // 1 byte, packed with b
}

Intermediate level Optimization Techniques

Inline Assembly

Inline assembly provides direct access to EVM opcodes, allowing for more gas-efficient operations in performance-critical code. While it sacrifices readability and safety checks, it can provide significant gas savings for specific operations like low-level memory management or bit manipulation. For performance-critical operations,the inline assembly can save significant gas:

// Traditional Solidity
function getFirst(uint256[] memory arr) public pure returns (uint256) {
    require(arr.length > 0, "Empty array");
    return arr[0];
}

// Assembly
function getFirstAssembly(uint256[] memory arr) public pure returns (uint256 result) {
    assembly {
// Check if array is empty
        if iszero(mload(arr)) {
            revert(0, 0)
        }
        // Load first element
        result := mload(add(arr, 0x20))
}
}

Minimizing External Calls

External calls are expensive because they require the EVM to load another contract’s code and execute it in a new context. Batching multiple operations into a single call can significantly reduce gas costs by sharing the base call cost across multiple operations thus they need to be optimized

// Inefficient: Multiple external calls
contract Inefficient {
    IERC20 token;

    function transferMultiple(address[] memory recipients, uint256 amount) external {
        for (uint256 i = 0; i < recipients.length; i++) {
token.transfer(recipients[i], amount); // External call in loop
        }
    }
}

// Optimized: Batched external calls
contract Optimized {
    IERC20 token;

    function transferMultiple(
        address[] memory recipients,
uint256[] memory amounts
    ) external returns (bool) {
        bytes memory data = abi.encodeWithSignature(
            "transferMultiple(address[],uint256[])",
            recipients,
            amounts
);
        (bool success, ) = address(token).call(data); // Single external call
        return success;
    }
}

Loops Optimization

Loops are a common source of high gas consumption. Key optimization strategies include caching array lengths, using unchecked math where safe, and minimizing storage access within loops.The following are the most important optimization techniques:

// Looser version
function sumArray(uint256[] memory values) public pure returns (uint256) {
uint256 sum = 0;
    for (uint256 i = 0; i < values.length; i++) {
        sum += values[i];
        // Accessing length in every iteration
        // Using memory variable for accumulation
    }
    return sum;
}

// Optimized loop
function sumArrayOptimized(uint256[] memory values) public pure returns (uint256) {
    uint256 sum;
    uint256 len = values.length;
    unchecked {
        // Using unchecked for gas optimization since overflow is impossible
        for (uint256 i; i < len; ++i) {
sum += values[i];
        }
    }
    return sum;
}

Advanced Optimization Techniques

Short-circuiting in Conditionals

In Solidity, when evaluating logical expressions with AND (&&) or OR (||) operators, the second condition is only evaluated if necessary based on the first condition’s result. By ordering conditions strategically – placing cheaper checks first and more expensive operations last – we can save gas by avoiding unnecessary evaluations. Save gas in conditional statements by ordering the conditions appropriately:

// Inefficient: Always evaluates both conditions
function processTransaction(uint256 amount, address recipient) external {
    require(recipient!= address(0) && amount > 0, "Invalid input");
}

// Optimized: Short-circuits on first failure
function processTransaction(uint256 amount, address recipient) external {
    require(amount > 0, "Invalid amount"); // Cheaper check first
    require(recipient != address(0), "Invalid recipient");
}

Bitmap Pattern for Multiple Booleans

Storing multiple boolean values as individual state variables is inefficient because each boolean uses a full storage slot (256 bits). By using a bitmap pattern, we can pack multiple flags into a single uint256, potentially storing up to 256 boolean values in the same storage slot. Instead of declaring multiple booleans, especially if used together to enable/disable functionalities, better use a bitmap:

// Inefficient: Multiple storage slots
contract Inefficient {
    bool public isActive;
    bool public isPaused;
bool public isFinalized;
    bool public isArchived;

    function setFlags(bool _active, bool _paused, bool _finalized, bool _archived) external {
        isActive = _active;
        isPaused = _paused;
        isFinalized = _finalized;
isArchived = _archived;
    }
}

// Optimized: storage slot size optimisation via bitmap
contract Optimized {
    uint8 private flags;

    // positions for flags in that array
    uint8 private constant IS_ACTIVE = 1;
    uint8 private constant IS_PAUSED = 1 << 1;
uint8 private constant IS_FINALIZED = 1 << 2;
    uint8 private constant IS_ARCHIVED = 1 << 3;

    function setFlags(bool _active, bool _paused, bool _finalized, bool _archived) external {
        flags = (_active? IS_ACTIVE : 0) |
(_paused? IS_PAUSED : 0) |
                (_finalized? IS_FINALIZED : 0) |
                (_archived? IS_ARCHIVED : 0);
    }
function isActive() external view returns (bool) {
        return flags & IS_ACTIVE!= 0;
    }
}

Fixed-size Arrays Over Dynamic Arrays

Dynamic arrays in Solidity incur additional gas costs for length management and storage operations. When the array size is known at compile time, using fixed-size arrays eliminates these overhead costs and provides better gas predictability. The storage layout is also more efficient since slot locations can be computed directly. When the size is fixed, fixed-size arrays are more gas-efficient:

// Inefficient: Dynamic array
contract Inefficient {
    uint256[] private values;

    function initialize() external {
// Always adds exactly 3 values
        values.push(1);
        values.push(2);
        values.push(3);
    }
}

// Optimized: Fixed-size array
contract Optimized {
    uint256[3] private values;

    function initialize() external {
values[0] = 1;
        values[1] = 2;
        values[2] = 3;
    }
}

Ordering of Function Input Validation

The order of input validations can significantly impact gas consumption, especially in functions that fail frequently. By performing cheaper validations first (like zero checks), we can fail fast and avoid unnecessary expensive computations (like signature verification) when basic validations fail. Order function input validation to minimize gas spent in case of a failure:

// Inefficient: expensive checks first, cheap checks last
function processPurchase(uint256 amount, bytes calldata data) external {
require(verify(data), "Invalid signature"); // Expensive
    require(amount > 0, "Invalid amount"); // Cheap check
    require(amount <= address(this).balance, "Insufficient balance");
}

// Optimized: Check cheaper checks first, then more expensive ones.
function processPurchase(uint256 amount, bytes calldata data) external {
    require(amount > 0, "Invalid amount"); // First check most likely cheap one.
    require(amount <= address(this).balance, "Insufficient balance");
require(verify(data), "Invalid signature"); // Expensive operation last
}

Optimized Error Handling

Custom errors, introduced in Solidity 0.8.4, are more gas-efficient than revert/require strings because they avoid storing error messages in contract bytecode. They also provide better debugging capabilities by allowing dynamic error data to be passed along with the revert. Using custom errors instead of requiring statements with strings:

// Inefficient: String error messages
contract Inefficient {
    function withdraw(uint256 amount) external {
        require(amount > 0, "Amount must be greater than 0");
        require(amount <= address(this).balance, "Insufficient balance");
    }
}

// Optimized: Custom errors
contract Optimized {
    error InvalidAmount();
    error InsufficientBalance(uint256 requested, uint256 available);

    function withdraw(uint256 amount) external {
        if (amount == 0) revert InvalidAmount();
        if (amount > address(this).balance)
            revert InsufficientBalance(amount, address(this).balance);
}
}

Event Parameter Packing

The gas cost of emitting events depends on how parameters are organized between indexed and non-indexed fields. Indexed parameters (topics) are stored separately and cost more gas, while non-indexed parameters are ABI-encoded together in the data field, allowing for more efficient packing. Order event parameters to optimize for gas cost efficiency:

// Inefficient: Unoptimized parameter ordering
contract Inefficient {
    event Transfer(
        address indexed from,
        address indexed to,
        uint256 amount,
        string memo,
        uint256 timestamp
    );
}

// Optimized: Parameters packed efficiently
contract Optimized {
    event Transfer(
        address indexed from,
        address indexed to,
        string memo, // Non-indexed parameters grouped together
        uint256 amount,
        uint256 timestamp
    );
}

Function Modifiers vs. Internal Functions

While modifiers improve code reusability, they can be less gas-efficient than internal functions because they inject code into each function they modify. Internal functions provide better gas efficiency through compiler optimization and give more control over execution flow. Replace modifiers with internal functions when possible:

// Inefficient: Using modifier
contract Inefficient {
    modifier validateInput(uint256 amount) {
        require(amount > 0, "Invalid amount");
        require(amount <= address(this).balance, "Insufficient balance");
        _;
    }
    
    function withdraw(uint256 amount) external validateInput(amount) {
        // Process withdrawal
    }
}

// Optimized: Using internal function
contract Optimized {
    function _validateInput(uint256 amount) internal view {
        if (amount == 0) revert InvalidAmount();
        if (amount > address(this).balance) revert InsufficientBalance();
    }
    
    function withdraw(uint256 amount) external {
        _validateInput(amount);
        // Process withdrawal
    }
}

Merkle Proofs for Large Datasets

For large datasets that need verification (like whitelists or airdrops), storing all data on-chain is prohibitively expensive. Merkle trees allow storing just a single root hash on-chain while keeping the data off-chain. Users prove their inclusion by providing a Merkle proof, which is validated against the root hash. For large data sets, it is much more gas-efficient to pass the Merkle proof rather than the full array of data:

// Inefficient: Storing full array
Inefficient Contract {
    address[] public whitelist;

    function isWhitelisted(address account) public view returns (bool) {
        for (uint256 i = 0; i < whitelist.length; i++) {
            if (whitelist[i] == account) return true;
        }
return false;
    }
}

// Optimized: Using Merkle proof
contract Optimized {
    bytes32 public immutable merkleRoot;

    constructor(bytes32 _merkleRoot) {
        merkleRoot = _merkleRoot;
    }

    function isWhitelisted(
        address account,
bytes32[] calldata proof
    ) public view returns (bool) {
        bytes32 leaf = keccak256(abi.encodePacked(account));
        return verifyMerkleProof(proof, merkleRoot, leaf);
    }
}

Gas Optimization Tools

Hardhat Gas Reporter

Hardhat Gas Reporter is a must-have tool for tracking gas consumption while developing. Add it to your Hardhat config:

require("hardhat-gas-reporter");

module.exports = {
  gasReporter: {
    enabled: true,
currency: 'USD',
    gasPrice: 21,
    coinmarketcap: process.env.COINMARKETCAP_API_KEY
  }
};

Remix Analyser

The Remix IDE has a native analyzer that is able to highlight some common gas optimizations. These often include, but are not limited to:

  • Functions can have state mutability restricted to pure/view
  • Consider making variable x a storage variable
  • Inefficient loop
  • Unused variable

Real-World Optimisation Example

Here’s a complete example, with optimization for a token vesting contract:

// Before optimization
contract TokenVesting {
    struct Vesting {
        uint256 amount;
        uint256 startTime;
        uint256 duration;
        uint256 released;
        bool revocable;
        bool revoked;
    }
    
    mapping(address => Vesting) public vestings;
    IERC20 public token;
    
    function createVesting(
        address beneficiary,
        uint256 amount,
        uint256 duration,
        bool revocable
    ) external {
        require(beneficiary != address(0), "Invalid beneficiary");
        require(amount > 0, "Invalid amount");
        require(duration > 0, "Invalid duration");
        
        vestings[beneficiary] = Vesting({
            amount: amount,
            startTime: block.timestamp,
            duration: duration,
            released: 0,
            revocable: revocable,
            revoked: false
        });
        
        token.transferFrom(msg.sender, address(this), amount);
    }
    
    function release(address beneficiary) external {
        Vesting storage vesting = vestings[beneficiary];
        require(vesting.amount > 0, "No vesting found");
        require(!vesting.revoked, "Vesting revoked");
        
        uint256 releasable = calculateReleasable(vesting);
        require(releasable > 0, "Nothing to release");
        
        vesting.released += releasable;
        token.transfer(beneficiary, releasable);
    }
    
    function calculateReleasable(Vesting memory vesting) internal view returns (uint256) {
        if (block.timestamp < vesting.startTime) {
            return 0;
        }
        
        uint256 timeElapsed = block.timestamp - vesting.startTime;
        if (timeElapsed >= vesting.duration) {
            return vesting.amount - vesting.released;
        }
        
        return (vesting.amount * timeElapsed / vesting.duration) - vesting.released;
    }
}

// After optimization
contract TokenVestingOptimized {
    // Pack related variables together
    struct Vesting {
        uint96 amount;     // Reduced from uint256 since 96 bits is usually sufficient
        uint48 startTime;  // Reduced from uint256, sufficient until year 2083
        uint48 duration;   // Reduced from uint256
        uint48 released;   // Packed in same slot as flags
        bool revocable;    // Packed with released
        bool revoked;      // Packed with released
    }
    
    mapping(address => Vesting) public vestings;
    IERC20 public immutable token; // Made immutable
    
    constructor(IERC20 _token) {
        token = _token;
    }
    
    function createVesting(
        address beneficiary,
        uint96 amount,
        uint48 duration,
        bool revocable
    ) external {
        if (beneficiary == address(0)) revert InvalidBeneficiary();
        if (amount == 0) revert InvalidAmount();
        if (duration == 0) revert InvalidDuration();
        
        // Use assembly for efficient storage writes
        assembly {
            mstore(0x00, beneficiary)
            mstore(0x20, vestings.slot)
            let slot := keccak256(0x00, 0x40)
            
            // Pack values into a single storage write
            sstore(
                slot,
                or(
                    or(
                        shl(160, amount),
                        shl(64, timestamp())
                    ),
                    or(
                        shl(16, duration),
                        revocable
                    )
                )
            )
        }
        
        // Single external call
        if (!token.transferFrom(msg.sender, address(this), amount)) revert TransferFailed();
    }
    
    function release(address beneficiary) external {
        Vesting storage vesting = vestings[beneficiary];
        if (vesting.amount == 0) revert NoVesting();
        if (vesting.revoked) revert VestingRevoked();
        
        uint256 releasable;
        
        // Optimized calculation using assembly
        assembly {
            let timeElapsed := sub(timestamp(), sload(add(vesting.slot, 1)))
            let duration := sload(add(vesting.slot, 2))
            
            switch gt(timeElapsed, duration)
            case 0 {
                // Not fully vested
                releasable := div(mul(sload(vesting.slot), timeElapsed), duration)
            }
            default {
                // Fully vested
                releasable := sload(vesting.slot)
            }
            
            // Subtract already released amount
            releasable := sub(releasable, sload(add(vesting.slot, 3)))
        }
        
        if (releasable == 0) revert NothingToRelease();
        
        // Update released amount
        vesting.released += uint48(releasable);
        
        // Single external call
        if (!token.transfer(beneficiary, releasable)) revert TransferFailed();
    }
}

Best Practices and Recommendations

  1. Always batch operations to minimize storage writes and external calls
  2. Use appropriate variable sizes and pack them efficiently
  3. Use unchecked blocks where overflow/underflow cannot happen
  4. Reduce SLOADs by caching storage in memory
  5. Define and use custom errors instead of revert strings
  6. Less loops – if possible, find gas efficient alternatives
  7. Assembly for complex logic/ critical paths
  8. Profile gas regularly during development

Conclusion

Gas optimization is an iterative process that needs to take into consideration the specific requirements and usage patterns of your contract. Some optimizations may seem minor in isolation, but taken together can make a big difference in operational costs. Always measure the impact of optimizations and ensure they do not compromise on readability or security in your contract. Try out our gas tracker tool to check the amount of gas consumption by your smart contract here.