Estimated time: 8 minutes read
Key strategies to optimize gas consumption in Solidity contracts, improving efficiency during deployment and execution. The article explores patterns and practical tips to balance reduced gas consumption with clean, maintainable code.
What is Gas Optimization in Solidity?
Gas optimization in Solidity involves programming Smart Contracts following a set of guidelines to reduce gas consumption, both in terms of deployment and execution.
Deploying and executing a Smart Contract on the blockchain incurs costs, known as gas, which must be paid as compensation for using the infrastructure.
The amount of gas consumed during the deployment of a Smart Contract is directly related to the storage space occupied by its code, the number of state variables defined, and the execution cost of the constructor during the Smart Contract initialization. A more detailed overview can be found in this article on the costs associated with creating a Smart Contract.
On the other hand, the gas consumed when invoking methods of a Smart Contract in a transaction (writing to the blockchain) is calculated based on the cost assigned to the operation codes (opcodes) that each executed function translates into. A complete list of EVM opcodes and their gas costs can be found at this link.
Gas Optimization Patterns in Solidity
Below are some of the most common patterns for reducing gas consumption in Solidity contracts.
This is by no means a complete list, as optimization often needs to be tailored based on the logic executed in each function of the code.
Additionally, deeper optimizations, such as including assembler sections or using bit-shifting for multiplications and divisions by powers of two, have been omitted.
The approach has been to balance clarity and cleanliness of the code with optimizations. Pushing optimization guidelines to the extreme can result in very obfuscated and hard-to-maintain source code.
On the other hand, the verification of compliance with certain optimizations can be automated using specific tools, such as Solhint. It explicitly highlights which rules would be covered by that tool.
List of Optimizations
- Avoid initializing a variable if its default value is suitable. This applies to both state and local variables. For example, this could be applied to initializing a “for” loop, avoiding the initialization of the “index” variable:
for (uint256 index; index < totalAssets; ++index) {
- Cache state variables to prevent incurring extra costs from multiple reads. In the second example, the state variable “value” is read only once, whereas in the first example, it is read twice.
uint256 constant MAX_VALUE = 1000;
contract Uncached {
uint256 public value;
function addOne() public {
require(value < MAX_VALUE);
value = value + 1;
}
}
contract Cached {
uint256 public value;
function addOne() public {
uint256 _value = value;
require(_value < MAX_VALUE);
value = _value + 1;
}
}
(Solhint) Pack structs when possible. This involves defining the elements of a struct sequentially to maximize storage space utilization. In the example, both “age” (8 bits) and “owner” (160 bits) can be packed into the same “slot” since they occupy less than 256 bits.
struct packedStruct {
uint8 age;
address owner;
uint256 tokens;
}
(Solhint) Keep string lengths under 32 bytes to fit into a single memory “slot.”
Use constant or immutable state variables (assigned during deployment) when their values do not change. This way, they are embedded in the contract’s bytecode rather than occupying storage space, which saves gas on reads.
Use mappings instead of arrays whenever possible as they generally offer gas savings on reads. Arrays are typically necessary when iteration over the data list is required. The following example illustrates how to replace an array with a mapping:
contract CommonArray {
uint256[] list;
constructor() {
list.push() = 1;
list.push() = 2;
list.push() = 3;
}
function valueAt(uint256 index) external view returns(uint256) {
return list[index];
}
}
contract MappingReplacement {
mapping(uint256 => uint256) list;
constructor() {
list[0] = 1;
list[1] = 2;
list[2] = 3;
}
function valueAt(uint256 index) external view returns(uint256) {
return list[index];
}
}
- (Solhint) In functions, use calldata parameters instead of memory when the value will not be modified.
function certifyBatch(InputCertificate[] calldata inputs) public {
....
}
- Use references to variables in storage (pointers) instead of copying them to memory when it offers a benefit. In the following example, gas is saved because using a pointer allows access to the desired element of the struct directly, rather than copying the entire struct to memory:
contract StoragePointerVsMemory {
struct User {
string name;
string surname;
uint256 lastUpdated;
}
constructor() {
users[0] = User("John", "Doe", block.timestamp);
}
mapping(uint256 => User) public users;
function returnLastUpdatedNotOptimized(uint256 _id) public view returns (uint256) {
User memory _user = users[_id];
uint256 lastUpdated = block.timestamp - _user.lastUpdated;
return lastUpdated;
}
function returnLastUpdatedOptimized(uint256 _id) public view returns (uint256) {
User storage _user = users[_id];
uint256 lastUpdated = block.timestamp - _user.lastUpdated;
return lastUpdated;
}
}
- Split conditions in “require” statements to save on checks. This is illustrated in the following example:
// require(a > 0 && b > 0) is splitted
function splittedRequire(uint256 a, uint256 b) external pure returns (uint256) {
require(a > 0);
require(b > 0);
return a + b;
}
- Avoid negation in “if” statement conditions to prevent the overhead of the “!” operator:
function notOptimized() public {
if (!condition) {
function1();
}
else {
function2();
}
}
function optimized() public {
if (condition) {
function2();
}
else {
function1();
}
}
- (Solhint) Use pre-increments/pre-decrements (++i/- -i) instead of post-increments/post-decrements (i++/i- -) to save on storage operations. This is especially useful in loops:
for (uint256 index; index < totalAssets; ++index) {
Use “unchecked” blocks when there is no risk of overflow or underflow to avoid extra checks introduced by the compiler. This is not necessary for auto-incrementing in “for” loops, as since compiler version 0.8.22, “unchecked” mode is automatically enabled for that section of code.
Whenever possible, prefer using “uint256” over smaller integer types or even “bool”, since they are automatically converted to `uint256`.
Leverage short-circuit evaluation in compound conditional expressions. In this way, the most likely condition to fail (for AND) or to be met (for OR) is evaluated first, so subsequent conditions will be often not evaluated, saving gas. Additionally, the first condition should be the least costly in terms of gas consumption.
Define public variables only when necessary to avoid extra deployment costs from the automatic generation of corresponding getters.
In general, use the Solidity optimizer with high values for the “runs” parameter to prioritize lower gas consumption during contract execution over slightly higher deployment costs.
(Solhint) Use “revert” and “require” with custom errors (available since Solidity version 0.8.26) instead of strings. If strings are used, limit them to less than 32 bytes.
error NotAllowed(uint256 amount, uint256 balance);
function transferWithRevert(uint256 amount) public {
if (amount > balance) {
revert NotAllowed(amount, balance);
}
....
}
function transferWithRequire(uint256 amount) public {
require(amount <= balance, NotAllowed(amount, balance));
....
}
NOTE: at least until version 2.22.12 of Hardhat, the only way to compile contracts using Custom Errors in ‘require’ statements is through the ‘via-ir’ pipeline.
- Store the length of arrays in memory within “for” loops to save on reads. This reduces the number of times the array length is accessed, which can save gas, especially in loops where the array length is repeatedly checked.
uint256 length = list.length;
for (uint256 i; i < length; ++i) {
// .....
}
Minimize external calls in the execution of a Smart Contract method. To achieve this, return as much information as possible in each function call.
Use fixed-size variables instead of variable-size ones. For example, prefer “bytes32” over “string” or “bytes”.
Utilize the EIP-1167 pattern for contracts that are deployed many times but invoked infrequently. This pattern helps to reduce deployment costs by using minimal code in the contract and delegating functionality to a separate implementation contract.
Replace OpenZeppelin libraries with more gas-efficient alternatives, such as Solady, when appropriate.
Conclusion: Balancing Lower Gas Consumption and Maintainable Code
Optimizing gas consumption in a contract—both during deployment and invocation—is crucial for managing transaction costs payd by users. This also helps reduce the burden on the blockchain, supporting its long-term scalability.
However, it’s important not to overlook another key aspect of web3 development best practices: clarity and readability of Smart Contracts. This enhances transparency and trust in their usage.
The goal should be to find a balance between clean, maintainable code and gas optimization.
Additionally, it’s advisable to verify that any modifications made to reduce gas consumption are tested with “gas reporting” tools available to developers, especially for more aggressive optimizations.
What do you think of the presented list of optimizations? Do you write your Smart Contracts with a focus on reducing gas consumption? Have you applied other effective improvements? Feel free to share your thoughts.
On my part, I can help you find the right balance in developing your Smart Contracts. Contact me, and we can discuss it further.