How to Write Gas Efficient Contracts in Solidity
Monday, 17 May 2021 · 40 min read · ethereum solidityComputations 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:
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
:
If you’re using Hardhat, you can find these files in the
artifacts/build-info/
directory that’s generated when you runhardhat compile
.
Let’s inspect these two fields more closely. We’ll inspect the storage layout of the following Solidity contract:
The storage
field for the above contract is an array where each element has the following form:
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:
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:
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, inslot
0uint96 balance
takes up 12 bytes, starting fromoffset
20 and still inslot
0bool alive
takes up 1 byte inslot
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:
you can do:
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:
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. The EVM will still occupy 256 bits, fill 8 bits with the uint variable and fill the extra bites with zeros. 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 bytes1
… bytes32
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:
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 accessedMSTORE
(saving a word to memory): 3 gasMLOAD
(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.
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:
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.
Tip #9: Cache external values in memory
When making repeated external calls when the values between calls do not change, cache the values:
Tip #10: > 0 is more expensive 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.
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 &&
:
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:
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:
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:
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.)
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 >
:
Tip #17: Use external
instead of public
where possible
Functions with the public
visibility modifier are costlier than external
. Default to using the external
modifier until you need to expose it to other functions within your contract.
Tip #18: internal
functions are cheaper than public
If a function is only callable by other functions internally, specify it as internal
. Internal functions are cheaper to call than public
functions.
Tip #19: Enable the Solidity Gas Optimizer
Set the Solidity optimizer runs
value to a low number to optimize for contract deployment costs. Alternatively, set the optimizer to a high number to optimize for run-time gas costs when functions are called on a contract.
Tip #20: Skip initializing default values
When Solidity variables are not set, they refer to a set of default values:
address
=address(0)
uint256
=0
bool
=false
Gas is spent when you unecessarily initialize variables to its default value:
Tip #21: Short circuit expensive operations
Short-circuiting works by ordering the lower-cost operation first so that the higher-cost operation is skipped if the first operation evaluates to true. For example:
Summary
In this post, we learned:
- How storage is laid out in Solidity contracts
- How to inspect the storage layout of your contracts
- Patterns and techniques to optimize the gas usage of your contracts
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.
📬 Get updates straight to your inbox.
Subscribe to my newsletter so you don't miss new content.