Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Gas Optimization Basics

Hello! In our last lesson, we mastered the art of measuring costs by estimating deployment and transaction fees using tools like testnet deployments, gas trackers, and hardhat-gas-reporter. This gave you a clear picture of the economic footprint of your smart contracts.

Today, we'll build directly on that knowledge. Now that you know how to measure cost, it's time to learn how to actively reduce it. Our goal is to apply basic gas optimization techniques to your Solidity code. For your token to be successful, it needs to be affordable for users to acquire, transfer, and interact with. High gas fees are a major barrier to adoption, so making your contracts efficient is not just a technical exercise—it's a critical business decision.

We'll explore the most impactful strategies, focusing on the "why" behind each one. Given your background as a developer, you're familiar with optimizing for performance, like CPU time or memory usage. Here, we'll translate that mindset to optimizing for a direct monetary cost, which is a core paradigm of blockchain development.

The Hierarchy of Cost in the EVM

Before diving into specific techniques, it's crucial to understand one fundamental concept: not all operations are created equal in the Ethereum Virtual Machine (EVM). There's a clear and dramatic hierarchy of cost.

EVM Gas Consumption Flow
This diagram shows a high-level view of how gas is consumed for various operations within the EVM, highlighting that interactions with account storage are a significant source of gas costs.

The most important takeaway is the cost difference between storage, memory, and computation:

  1. Storage (Most Expensive): Interacting with the blockchain's state. The SSTORE opcode, which writes or changes a value in a contract's storage, is one of the most expensive operations in the EVM. Reading from storage with SLOAD is also costly, though less so than writing.
  2. Memory (Cheap): A temporary, volatile data area that exists only for the duration of a function's execution. Reading from and writing to memory is significantly cheaper than storage.
  3. Stack/Computation (Cheapest): The EVM uses a stack for most computations (e.g., arithmetic like ADD, MUL). These are the cheapest operations of all.

The Golden Rule of Gas Optimization: Minimize expensive storage interactions. Most of the following techniques are simply different ways of applying this rule.

Technique 1: Cache Storage Variables in Memory

This is one of the most common and highest-impact optimizations you can make. If you need to read the same state variable multiple times within a single function execution, you should not read it from storage each time. Instead, load it into a local memory variable once at the beginning of the function and use that local variable for all subsequent operations.

This video provides an excellent deep dive into this exact principle, showing the gas savings at each step.

Spend Less Gas - Solidity Gas Optimization [Deep Dive]

The video "Spend Less Gas - Solidity Gas Optimization [Deep Dive]" by Moralis for Developers clearly demonstrates the power of caching. You'll see a function optimized in stages, with the gas costs compared at each step.

Watch the video from the beginning up to 04:40. Pay close attention to the evolution from functionA to functionB and finally to functionC. The narrator does a great job explaining why each change reduces gas by avoiding repeated SSTORE and SLOAD operations.

As you saw, the key is to avoid accessing storage within a loop.

  • Bad (Expensive): Reading an array's length and elements directly from storage in every loop iteration.
    // Inefficient: `myArray.length` and `myArray[i]` are SLOADs inside a loop
    for (uint i = 0; i < myArray.length; i++) {
        sum += myArray[i];
    }
    
  • Good (Cheaper): Caching the array in a memory variable first.
    // Efficient: The entire array is copied to memory once.
    // The loop then operates on the cheap memory copy.
    uint[] memory localArray = myArray;
    for (uint i = 0; i < localArray.length; i++) {
        sum += localArray[i];
    }
    

Technique 2: Efficient Data Storage and Access

How you structure, store, and pass data has a massive impact on gas. Let's explore a few related techniques, which are covered in detail in the following article.

12 Solidity Gas Optimization Techniques

The article "12 Solidity Gas Optimization Techniques" from Alchemy is an excellent, comprehensive guide. We will use it as a reference for several key techniques related to efficient data management.

Read the following sections from the article: "Use mappings instead of arrays" "Store data in calldata instead of memory..." "Use immutable and constant fixed values" "Pack your variables" Focus on the code examples and the explanation of why each technique saves gas.

Let's summarize the key points from that reading.

a) Mappings vs. Arrays for Lookups

For a backend developer, this is analogous to choosing between a hash map and a list.

  • Arrays: Finding an item requires iteration (O(n) complexity). This can get extremely expensive as the array grows.
  • Mappings: Provide direct, key-based lookups (O(1) complexity). This is always a fixed, low cost.

Guideline: For any key-value data, like tracking user balances (address => uint256), always use a mapping. Use arrays only when you truly need to iterate over the entire collection of items.

b) calldata vs. memory for Function Arguments

When passing data like arrays or strings to an external function, you have a choice.

  • memory: The data is copied from the transaction payload into memory. This costs gas.
  • calldata: This is a special, read-only location where function arguments live. Using calldata tells the EVM to read directly from the transaction payload, avoiding a copy.

Guideline: Always use calldata for parameters of external functions unless you need to modify the data within the function.

c) constant and immutable Variables

If you have a variable whose value will never change, don't waste a storage slot on it.

  • constant: For values known at compile time (e.g., uint256 public constant DECIMALS = 18;).
  • immutable: For values set once in the constructor (e.g., address public immutable owner;).

Both constant and immutable variables have their values baked directly into the contract's bytecode at deployment. When you access them, the EVM reads from the cheap bytecode instead of performing an expensive SLOAD.

d) Variable Packing

The EVM stores data in 32-byte (256-bit) slots. If you declare multiple variables that are smaller than 32 bytes, Solidity can sometimes pack them into a single slot. This can save significant gas on deployment and on subsequent writes.

  • Unpacked (Expensive):
    uint128 a; // Slot 0
    uint256 b; // Slot 1
    uint128 c; // Slot 2 (3 slots used)
    
  • Packed (Cheaper):
    uint128 a; // Slot 0
    uint128 c; // Slot 0 (packed with a)
    uint256 b; // Slot 1 (2 slots used)
    

Guideline: Group smaller data types (e.g., uint128, bool, address) together in your state variable declarations to encourage packing.

Technique 3: Fine-Tuning with Compiler and Language Features

Beyond data structures, there are language features and compiler settings that provide further optimization.

a) Using unchecked Blocks Safely

Since Solidity version 0.8.0, all arithmetic operations include checks for overflow and underflow. This is a great safety feature, but it adds a small gas cost to every operation. In situations where you are mathematically certain that an overflow/underflow is impossible, you can tell the compiler to skip these checks.

The "Spend Less Gas" video you watched earlier introduces this for a loop counter.

Spend Less Gas - Solidity Gas Optimization [Deep Dive]

Let's revisit the "Spend Less Gas" video to see how unchecked blocks can be used safely to optimize a loop.

Watch the section from 04:40 to 08:24. The video introduces a new functionD that uses an unchecked block for the loop incrementor (i++). Notice the reasoning: it's virtually impossible for a loop counter i to overflow a uint256.

Guideline: The most common and safest use case for unchecked is with loop counters.

// Looping through an array
for (uint i = 0; i < items.length;) {
    // process item...
    unchecked { ++i; } // Safe and saves gas on every iteration
}

Warning: Be extremely careful with unchecked. Only use it when you have validated inputs or are 100% certain no overflow/underflow can occur. Misusing it can lead to critical vulnerabilities.

b) Enabling the Solidity Compiler Optimizer

This is one of the easiest and most effective ways to save gas. The Solidity compiler has a built-in optimizer that reduces code size and improves efficiency. You should always have it enabled for production deployments.

You can configure this in your hardhat.config.js:

// in hardhat.config.js
module.exports = {
  solidity: {
    version: "0.8.20",
    settings: {
      optimizer: {
        enabled: true,
        runs: 200, // The number of times you expect functions to be called
      },
    },
  },
  //...
};

The runs parameter tells the optimizer what to prioritize.

  • Low runs (e.g., 200): Optimizes for cheap deployment cost, at the expense of slightly higher function call costs. Good for contracts that are deployed often.
  • High runs (e.g., 10000): Optimizes for cheap function call costs, at the expense of a more expensive one-time deployment. Good for contracts like your ERC-20 token, which will be deployed once but whose functions (transfer, approve) will be called many times. For your project, a higher runs value is likely appropriate.
Test your understanding!

You are writing a function to pay out dividends to a list of whitelisted investors. The unoptimized version looks like this:

contract DividendPayout {
    address[] public whitelistedInvestors;
    mapping(address => uint) public balances;

    // Assume whitelistedInvestors and balances are populated

    function payDividends(uint totalDividend) public {
        uint share = totalDividend / whitelistedInvestors.length;

        for (uint i = 0; i < whitelistedInvestors.length; i++) {
            address investor = whitelistedInvestors[i];
            balances[investor] += share;
        }
    }
}

Based on what you've learned, what are at least three gas optimizations you could apply to this payDividends function and the contract structure?

Show answer
  1. Cache whitelistedInvestors in memory: The for loop accesses whitelistedInvestors.length and whitelistedInvestors[i] from storage on every iteration. This is very expensive. You should copy the array into a memory variable before the loop.
  2. Cache whitelistedInvestors.length: Even better, you can cache the length in a local variable to avoid reading it from memory in each loop iteration.
  3. Use unchecked for the loop counter: i is a perfect candidate for an unchecked { ++i; } block, as it's not at risk of overflowing.
  4. Use external visibility: The function is intended to be called from outside the contract, so changing public to external would be slightly more gas-efficient (though the other optimizations have a much larger impact).

A more optimized version would look like this:

function payDividends(uint totalDividend) external { // Use external
    address[] memory investors = whitelistedInvestors; // Cache array in memory
    uint len = investors.length;
    uint share = totalDividend / len;

    for (uint i = 0; i < len;) {
        balances[investors[i]] += share;
        unchecked { ++i; } // Use unchecked for counter
    }
}

Conclusion

You've now learned the foundational techniques for writing gas-efficient Solidity code. By applying these principles, you directly reduce the costs for your users, making your entire ecosystem more appealing and sustainable. Remember, you can always use the hardhat-gas-reporter tool from our previous lesson to verify the impact of your optimizations.

Key Takeaways:

  • The Cost Hierarchy: Storage is the most expensive resource. Your primary goal is to minimize reads from and writes to it.
  • Cache in Memory: Always load storage variables into memory if you need to access them multiple times in a function.
  • Use Efficient Data Patterns: Prefer mappings for lookups, calldata for external function arguments, and constant/immutable for fixed values.
  • Optimize Your Code: Pack small variables to save storage slots and use unchecked arithmetic for safe, low-level optimizations like loop counters.
  • Enable the Compiler Optimizer: This is a simple and powerful way to get significant gas savings with minimal effort.

Preview of the Next Lesson:

We have now configured our project for the mainnet, learned how to estimate costs, and optimized our code for efficiency. All the preparatory work is complete. In our next lesson, we will take the final, exciting step in this module: Perform the final deployment of your token and associated contracts to the Ethereum mainnet.

Can't find a good explanation? Sign up and we'll make it for you

Sign up