How to Write Gas Efficient Contracts in Solidity

Computations on Ethereum cost gas. If you’re not careful, you may end up writing contracts that cost more than they should. High gas costs kill usability!

A common cause for high gas costs is an inefficient storage layout in Solidity contracts. In this post, let’s look at some tips you can use to help you write more gas-efficient contracts.

Read on to learn more!

Storage Layout in Solidity

When state variables of Solidity contracts are stored in storage, they are stored in a compact way such that multiple values sometimes use the same storage slot. Except for dynamically-sized arrays and mappings, data is stored contiguously as 32-byte words, item after item starting with the first state variable, which is stored in slot 0.

The size of each variable is determined by its type. Here is a summary of Solidity value types and their size in bytes:

type bytesize
bool 1
bytes1 1
bytes8 8
bytes32 32
address 20
contract 20
uint8 1
uint16 2
uint32 4
uint128 16
uint256 32
int256 32

Here’s an example of how a contract’s storage might be laid out:

Storage Layout

In Example #2, multiple variables of value types may share a storage slot, within which each variable only takes up as much space as it need to.

Any items with a size under 32 bytes are packed into a single storage slot if possible, according to the following rules:

  • Value types use only as many bytes as are necessary to store them.

  • If a value type does not fit the remaining part of a storage slot, it is stored in the next storage slot.

  • Structs and array data always start a new slot, but their items are packed tightly according to these rules.

  • Items following struct or array data always start a new storage slot.

In short, optimizing storage layout in Solidity is like trying to pack items into the least number of boxes. The less boxes we need, the better.

Inspecting Storage Layout

Before we proceed, we need a way to inspect what our contracts’ storage layout looks like.

The storage layout of a contract can be viewed in the Standard JSON interface files which comes from the Solidity compiler. The output file is a JSON object containing two keys, storage and types:

{
    "storageLayout": {
      "storage": [],
      "types": {}
    }
}

If you’re using Hardhat, you can find these files in the artifacts/build-info/ directory that’s generated when you run hardhat compile.

Let’s inspect these two fields more closely. We’ll inspect the storage layout of the following Solidity contract:

contract MyContract { address owner; }

The storage field for the above contract is an array where each element has the following form:

"storage": [{
    "astId": 2,
    "contract": "contracts/MyContract.sol:MyContract",
    "label": "owner",
    "offset": 0,
    "slot": "0",
    "type": "t_address"
}]

Each storage element contain the following fields:

  • astId is the id of the AST node of the state variable’s declaration

  • contract is the name of the contract including its path as prefix

  • label is the name of the state variable

  • offset is the offset in bytes within the storage slot according to the encoding

  • slot is the storage slot where the state variable resides or starts. This number may be very large and therefore its JSON value is represented as a string.

  • type is an identifier used as key to the variable’s type information (described in the following)

In addition to the storage, the JSON file contains types. In the following example, t_uint256 represents an element in types, which has the form:

"types": {
  "t_address": {
    "encoding": "inplace",
    "label": "address",
    "numberOfBytes": "20"
  }
}

You can inspect the storage and types fields to understand how much space each type consumes and how they are packed. Here’s a larger example:

"types": {
  "t_address": {
    "encoding": "inplace",
    "label": "address",
    "numberOfBytes": "20"
  },
  "t_bool": {
    "encoding": "inplace",
    "label": "bool",
    "numberOfBytes": "1"
  },
  "t_uint96": {
    "encoding": "inplace",
    "label": "uint96",
    "numberOfBytes": "12"
  }
  "t_struct(MyInfo)476_storage": {
    "encoding": "inplace",
    "label": "struct MyContract.MyInfo",
    "members": [
      {
        "astId": 471,
        "contract": "contracts/MyContract.sol:MyContract",
        "label": "account",
        "offset": 0,
        "slot": "0",
        "type": "t_address"
      },
      {
        "astId": 473,
        "contract": "contracts/MyContract.sol:MyContract",
        "label": "balance",
        "offset": 20,
        "slot": "0",
        "type": "t_uint96"
      },
      {
        "astId": 475,
        "contract": "contracts/MyContract.sol:MyContract",
        "label": "alive",
        "offset": 0,
        "slot": "1",
        "type": "t_bool"
      }
    ],
    "numberOfBytes": "64"
  }
}

In the above file, we can see that:

  • An address takes up 20 bytes
  • A boolean takes up 1 byte
  • A uint96 takes up 12 bytes
  • A struct MyInfo occupies 64 bytes, and has 3 variables packed inside it:
    • address account takes up 20 bytes, in slot 0
    • uint96 balance takes up 12 bytes, starting from offset 20 and still in slot 0
    • bool alive takes up 1 byte in slot 1, because slot 0 is already full at 32 bytes

You can generate and use this report to inspect how your contracts’ storage is laid out. Try it out with some dummy contracts!

Armed with this knowledge, let’s learn some tips to optimize our Solidity contracts!

Tip #1: Variable packing

Reading and writing from each storage slot cost gas. Packing variables lets us reduce the number of slots our contract needs.

To allow the EVM to optimize your storage layout, make sure to order your storage variables and struct members such that they can be packed tightly. For example, instead of declaring your storage variables in the order of:

// First approach

uint128, // 16 -> slot 0 16/32
uint256, // 32 -> slot 1 32/32 (doesn't fit slot 0)
uint128 // 16 -> slot 2 16/32

you can do:

// Second approach

uint128, // 16 -> slot 0 16/32
uint128, // 16 -> slot 0 32/32 (fits slot 0)
uint256 // 32 -> slot 1 16/32

The first approach results in no variable packing. It takes up three storage slots.

In comparison, the second approach benefits from variable packing and takes only two slots.

Here’s another variable packing example:

Storage Layout Before

Storage Layout After

In short, group Solidity variables into chunks of 32-byte words to optimize gas usage.

Tip #2: uint256 can be cheaper than uint8

When using types that are smaller than 32 bytes such as uint8, your contract’s gas usage may in some cases be higher than if you use uint256.

This is because the EVM operates on 32 bytes at a time. Therefore, if the element is smaller than that, the EVM has to do more operations to convert the element between 32 bytes and the desired size.

It might be beneficial to use smaller-size types if you can get the compiler to pack multiple types into one storage slot (see the previous section on Variable packing), and thus, combine multiple reads or writes into a single operation.

However, if you are not reading or writing all the values at the same time, this can have the opposite effect. When defining a lone variable, it can be more optimal to use a uint256 rather than uint8.

Tip #3: Use bytesN instead of bytes[]

The value types bytes1bytes32 hold a sequence of bytes from one to up to 32. You should use bytes over bytes1[] because it is cheaper, since bytes1[] adds 31 padding bytes between the elements. Due to padding rules, this wastes 31 bytes of space for each element.

If you can limit the length to a certain number of bytes, always use one of the value types bytesN because they are more gas efficient.

Tip #4: Use fixed-size bytes32 instead of string / bytes

Fitting your data in fixed-size 32 byte words is much cheaper than using arbitrary-length types. Remember that bytes32 uses less gas because it fits in a single EVM word. Basically, Any fixed size variable in solidity is cheaper than dynamically sized ones.

Tip #5: Write to storage last

Only update storage variables with the final results of your computation, after all intermediate calculations instead of at each step. This is because operations on storage is more expensive than operations on memory / calldata.

Let’s look an example contract:

pragma solidity ^0.8.0;

contract Test {
  uint internal count;

  function A() public {
      for (uint i = 0; i < 10; i++){
          count++; // Writes to storage at every intermediate step
      }
  }

  function B() public {
      uint j;
      for (uint i = 0; i < 10; i++){
          j++;
      }
      count = j; // Writes only the final result to storage
  }
}

In the sample code above, function B() is more gas-efficient than A() because it does less storage writes. Use local variables to store your intermediate results, and write to storage last.

Here is a cost comparison:

  • SSTORE (saving a 32-byte word to storage): 22100 gas when storage value is set to non-zero from zero, OR 5000 gas when the storage value’s zeroness remains unchanged or is set to zero. This assumes first-time access.
  • SLOAD (loading a word from storage): 2100 gas on first-time access, 100 gas if already accessed
  • MSTORE (saving a word to memory): 3 gas
  • MLOAD (loading a word from memory): 3 gas

As you can see, the difference is potentially massive. Minimize writes to storage and do them last. Note that are numbers are post-Berlin upgrade numbers (EIP 2929.)

You might also notice that ‘cold’ reads on the EVM are more expensive than ‘warm’ reads. This is yet another reason for miniziming the number of storage slots and variable packing (Tip #1.)

Tip #6: Free up unused storage

You get a gas refund when you ‘empty out’ variables. Deleting has the same effect as reassigning the value type with the default value, such as the zero address for addresses and 0 for integers.

uint internal count;

delete count; // Clears count to the default uint value (0)

Deleting a variable refunds 15,000 gas up to a maximum of half the gas cost of the transaction.

Tip #7: Use constant and immutable keywords

Add constant and immutable type annotations to variables:

contract C {
    bytes32 constant MY_HASH = keccak256("abc");
    address immutable owner;

    constructor() public {
        owner = msg.sender
    }
}

Reading constant and immutable variables is no longer considered a storage read (SSTORE) because it uses EXTCODE* opcodes instead which is much cheaper.

Tip #8: Cache storage values in memory

Anytime you are reading from storage more than once, it is cheaper to cache variables in memory. An SLOAD cost 100 GAS while MLOAD and MSTORE only cost 3 GAS.

This is especially true in for loops when using the length of a storage array as the condition being checked after each loop. Caching the array length in memory can yield significant gas savings when the array length is high.

// Before
for(uint 1; i < approvals.length; i++); // approvals is a storage variable

// After
uint memory numApprovals = approvals.length;
for(uint 1; i < numApprovals; i++);

Tip #9: Cache external values in memory

When making repeated external calls when the values between calls do not change, cache the values:

// Before
require(core.controller().hasRole(core.controller().MANAGER_ROLE(), msg.sender));

// After
IController memory controller = core.controller();
require(controller.hasRole(controller.MANAGER_ROLE(), msg.sender));

Tip #10: > 0 is less efficient than != 0 for unsigned integers

!= 0 costs 6 less GAS compared to > 0 for unsigned integers in require statements with the optimizer enabled. Learn more.

// Before
require(_value > 0);

// After
require(_value != 0);

Tip #11: Splitting require() statements that use && saves gas

Instead of using the && operator in a single require statement to check multiple conditions,using multiple require statements with 1 condition per require statement will save 3 GAS per &&:

// Before
require(result >= MIN_64x64 && result <= MAX_64x64);

// After
require(result >= MIN_64x64);
require(result <= MAX_64x64);

Tip #12: ++i costs less gas compared to i++ or i += 1

++i costs less gas compared to i++ or i += 1 for unsigned integers. This is because the pre-increment operation is cheaper (about 5 GAS per iteration).

i++ increments i and returns the initial value of i. This means:

uint i = 1;
i++; // == 1 but i == 2

In the first example, the compiler has to create a temporary variable (when used) for returning 1 instead of 2.

In contrast, ++i returns the actual incremented value:

uint i = 1;
++i; // == 2 and i == 2 too, so no need for a temporary variable

This tip is applicable for loops as well.

Tip #13: Shorten require messages to less than 32 characters

Strings that are more than 32 characters will require more than 1 storage slot, costing more gas. Consider reducing the message length to less than 32 characters or use error codes:

// Before
require(value > 2, "This require statement message exceeds thirty two characters.");

// After
require(value > 2, "A201")

Tip #14: Use Custom Errors instead of Revert Strings to save Gas

Consider replacing revert strings with custom errors. Custom errors from Solidity 0.8.4 are cheaper than revert strings (cheaper deployment cost and runtime cost when the revert condition is met.)

function withdraw() public {
    if (msg.sender != owner)
        revert Unauthorized();

    owner.transfer(address(this).balance);
}

Starting from Solidity v0.8.4, there is a convenient and gas-efficient way to explain to users why an operation failed through the use of custom errors. Until now, you could already use strings to give more information about failures (e.g., revert(“Insufficient funds.”);), but they are rather expensive, especially when it comes to deploy cost, and it is difficult to use dynamic information in them.

Tip #15: Usage of uint8 may increase gas cost

When using elements that are smaller than 32 bytes, your contract’s gas usage may be higher. This is because the EVM operates on 32 bytes at a time. Therefore, if the element is smaller than that, the EVM must use more operations in order to reduce the size of the element from 32 bytes to the desired size. The EVM needs to properly enforce the limits of this smaller type.

It is only beneficial to use reduced-size arguments if you are dealing with storage values because the compiler will pack multiple elements into one storage slot, and thus, combine multiple reads or writes into a single operation. When dealing with function arguments or memory values, there is no inherent benefit because the compiler does not pack these values.

Using smaller-size uints such as uint8 is only more efficient when you can pack variables into the same storage slot, such as in structs. In other cases and in loops, uint256 is more gas efficient than uint8.

Tip #16: Non-strict inequalities are cheaper than strict ones

In the EVM, there is no opcode for non-strict inequalities (>=, <=) and two operations are performed (> + =.) Consider replacing >= with the strict counterpart >:

// Before
require(value >= 2);

// After
require(value > 3);

Summary

In this post, we learned:

  • How storage is laid out in Solidity contracts
  • How to inspect the storage layout of your contracts
  • Some techniques to optimize storage layout and access

Keep in mind that storage optimization techniques can run counter to code readability. When in doubt, be sure to aim for a balance between efficiency and readability.