Gas Efficient Tips to Optimize Storage Layout 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.

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 code efficiency and readability.