Ethereum is widely recognized as the leading blockchain platform, but ongoing concerns exist about its fluctuating transaction fees. To combat this issue, a number of L2s, such as Base, have been developed with a focus on scalability and minimizing gas costs. While L2s offer significant reductions in gas fees compared to the Ethereum mainnet, smart contract developers are still responsible for prioritizing gas optimization during development. By doing so, they can enhance the user experience and create more competitive dApps.
In this guide on the best Solidity gas optimization tips and techniques, you will learn advanced, real-world, and tested strategies taught by skilled web3 developers to reduce the gas costs of your smart contracts.
Keep in mind that the examples in this guide come from really simple contracts and are for demonstration purposes only. In most cases, they only take into consideration runtime gas costs, as deployment costs can vary significantly based on the size of the smart contract.
In real-life scenarios, we strongly suggest that each smart contract undergo a complete, in-depth auditing process.
For all examples and tests in this article, you can refer to the Github gas optimization tips repository.
Before getting started with this web3 development guide, let’s quickly refresh why gas optimization is important!
Gas optimization is crucial for developers, users, and the long-term success of projects and protocols. Efficiently optimizing the gas of your smart contracts will make your protocol more cost-effective and scalable while reducing security risks such as denial of service (DoS) attacks.
Gas-efficient contracts enable faster and cheaper transactions, even under congested network conditions, improving your product and user experience.
Simply put, optimizing gas costs makes Solidity smart contracts, protocols, and projects:
Furthermore, improving a smart contracts’ code helps uncover potential vulnerabilities, making your protocol and its users more secure.
Note: This guide does not substitute for a thorough security review of your contracts by top smart contracts auditing firms in web3.
In summary, gas optimization should be a key focus during development. It isn't simply a “nice-to-have,” but a must-have for your smart contracts' long-term success and security. Just because you are building on an L2 with relatively low fees does not mean that your gas fees won’t be dramatically higher than your competitor’s!
Let's delve into the most effective techniques for optimizing gas usage.
Disclaimer: all tests in this guide are executed using Foundry with the following setup:
NFTs gained popularity in 2021, and with that came a growing interest in fully on-chain NFTs. Unlike traditional NFTs, which reference off-chain data such as metadata and images, on-chain NFTs store everything directly on the blockchain. These tokens are notoriously expensive to interact with, and hybrid solutions quickly became the norm when users saw the impact this had on the fees.
As a developer, it is crucial to question the necessity of recording data on-chain. Whether you are creating an NFT, a game, or a DeFi protocol, always question what data actually needs to be stored on-chain and consider the trade-offs for both options.
You can significantly reduce the gas consumption of your smart contracts by storing information off-chain, because you allocate less storage to store variables.
One practical approach is to use events to store data off-chain instead of storing data directly on-chain. Events inevitably increase transaction gas costs due to the extra emit function; however, the savings generated by not storing the information on-chain often outweigh the cost.
Let us review a smart contract that allows its users to vote `true` or `false. In the first example, we will store a user’s vote in a struct on-chain.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
contract InefficientVotingStorage {
struct Vote {
address voter;
bool choice;
}
Vote[] public votes;
function vote(bool _choice) external {
votes.push(Vote(msg.sender, _choice));
}
}
Testing the vote function using Foundry over 100 times, we get these results:
To compare, let’s look at a smart contract that does not store the information on-chain, but emits an event each time the vote function is called.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
contract EfficientVotingEvents {
event Voted(address indexed voter, bool choice);
function vote(bool _choice) external {
emit Voted(msg.sender, _choice);
}
}
Testing the new vote function using Foundry over 100 times, we get these results:
As you can see, minimizing on-chain data saved an outstanding 90.34% gas on average.
To access off-chain data, on-chain, you can use a solution like Chainlink functions, which supports most popular L2 networks, including Base.
Test results of gas usage, minimizing on-chain data with Solidity:
Before optimization: 23,564
After optimization: 2,274
Average reduction: 90%
Test link on GitHub.
Solidity offers two primary data structures to manage data: arrays and mappings. Arrays store a collection of items, each assigned to a specific index. Mappings, on the other hand, are key-value data structures that provide direct access to data through unique keys.
While arrays might be useful for storing vectors and similar data, mappings are generally preferred for their gas efficiency. They are particularly well-suited for scenarios where data needs to be retrieved on demand, such as names, wallet addresses, or account balances.
To best understand the gas costs incurred when using an array or a mapping, we need to review the gas consumed by the related EVM opcodes. Opcodes are the low-level instructions that the Ethereum Virtual Machine (EVM) executes when running smart contracts, and each opcode has a gas cost.
To retrieve a value by looping through every item of an array, we must pay for each unit of gas consumed by the associated EVM opcodes.
To illustrate this concept, here's an example illustrating the use of arrays and their equivalent mappings in Solidity:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
contract UsingArray {
struct User {
address userAddress;
uint256 balance;
}
User[] public users;
function addUser(address _user, uint256 _balance) public {
users.push(User(_user, _balance));
}
// Function to simulate user retrieval as would be required in an array
function getBalance(address _user) public view returns (uint256) {
for (uint256 i = 0; i < users.length; i++) {
if (users[i].userAddress == _user) {
return users[i].balance;
}
In the above example, we are using an array to store users' addresses and corresponding balances. To retrieve a user’s balance, we’ll have to loop through each item, determine if the userAddress
matches the _userAddress
argument, and if it matches, return the balance. Messy, right?
Instead, we can use a mapping to directly access the balance of a particular user without having to iterate through all the elements in the array:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
contract UsingMapping {
mapping(address => uint256) public userBalances;
function addUser(address _user, uint256 _balance) public {
userBalances[_user] = _balance;
}
// Function to fetch user balance directly from mapping
function getBalance(address _user) public view returns (uint256) {
return userBalances[_user];
}
}
In this test, after substituting the array with a mapping, retrieving the data used 89% less gas!
Also, the cost of adding data to a mapping is 93% less than adding to an array.
Test results of gas usage when retrieving data from mappings over arrays in Solidity:
Before optimization: 30,586
After optimization: 3,081
Average savings: 89%
Test link on GitHub.
Another tip when optimizing gas costs of your Solidity smart contracts is to use constants and immutable variables. When declaring variables as immutable or constant in Solidity, values are assigned exclusively during contract creation and become read-only thereafter.
Unlike other variables, they do not consume storage space within the EVM. Their values are instead compiled directly into the smart contract bytecode, resulting in reduced gas costs associated with storage operations.
Take into consideration this example:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
contract InefficientRegularVariables {
uint256 public maxSupply;
address public owner;
constructor(uint256 _maxSupply, address _owner) {
maxSupply = _maxSupply;
owner = _owner;
}
}
As you can see below in our Foundry test, we’re declaring our variables maxSupply and owner without using the constant or immutable keywords. Running our test 100 times, we get an average gas cost of 112,222 units.
As maxSupply and owner are known values and aren’t planned to be changed, we can declare a maximum supply and an owner for our smart contract that does not consume any storage space.
Let’s add the constant and immutable keywords, slightly changing the declaration:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
contract ConstantImmutable {
uint256 public constant MAX_SUPPLY = 1000;
address public immutable owner;
constructor(address _owner) {
owner = _owner;
}
}
By simply adding the immutable and constant keywords to our variables in our Solidity smart contract, we have optimized the average gas spent by a significant 35.89%.
Gas usage test using constant or immutable variables in Solidity:
Before optimization: 112,222
After optimization: 71,940
Average savings: 35.89%
Test link on Github.
Optimizing the variables in a Solidity smart contract is an obvious gas optimization tip. However, unusable variables are often kept in the execution of smart contracts, resulting in avoidable gas costs.
Take a look at the following example of a bad use of variables:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
contract InefficientContract {
uint public result;
uint private unusedVariable = 10;
function calculate(uint a, uint b) public {
result = a + b; // Simple operation to use as a test
// This next line alters the state unnecessarily, wasting gas.
unusedVariable = unusedVariable + a;
}
In this contract, unusedVariable
is declared and manipulated in the calculate function, but it is never used anywhere else.
Let’s see how much gas that unused variable is costing us:
Let’s now optimize our contract by removing the unusedVariable:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
contract EfficientContract {
uint public result;
function calculate(uint a, uint b) public {
result = a + b; // Only the necessary operation is performed
}
}
As you can see, just by removing one single unused variable in our smart contract, we’re able to reduce gas costs by an average of 18%.
Gas usage test removing unused variables in Solidity:
Before optimization: 32,513
After optimization: 27,429
Average savings: 18%
Test link on Github.
Deleting unused variables doesn’t mean “deleting” them—this would cause all sorts of issues with pointers in memory. It's more like assigning a default value back to a variable once a calculation is completed to avoid the data being pushed to storage.
For example, the default value for a uint
variable is 0.
Let’s take a look at a simple example:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
contract WithoutDelete {
uint public data;
function skipDelete() public {
data = 123; // Example operation
// Here we're not using delete
}
}
In this case, by not deleting the data variable once the function ends, we pay an average of 100,300 gas units just to assign that variable to data
.
Now let’s see what happens when we use the delete
keyword:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
contract WithDelete {
uint public data;
function useDelete() public {
data = 123; // Example operation
delete data; // Reset data to its default value
}
}
By deleting our data
variable (setting its value back to 0), we save 19% gas on average!
Gas usage test deleting unused variables in Solidity:
Before optimization: 100,300
After optimization: 80,406
Average savings: 19%
Test link on Github.
As mentioned earlier, you should use mappings whenever possible to optimize the gas of your Solidity smart contracts.
However, if you need to use arrays in your contracts, it's more gas efficient to use fixed-sized arrays than dynamically sized ones because dynamically sized ones can grow indefinitely, resulting in higher gas costs.
Simply put, fixed-sized arrays have known lengths.
On the other hand, dynamically sized arrays can grow in size, and the EVM must keep track of its length and update it each time a new item is added.
Let’s take a look at the following code, where we declare a dynamically sized array and update it through the updateArray
function.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
contract DynamicArray {
uint256[] public dynamicArray;
constructor() {
dynamicArray = [1, 2, 3, 4, 5]; // Initialize the dynamic array
}
function updateArray(uint256 index, uint256 value) public {
require(index < dynamicArray.length, "Index out of range");
dynamicArray[index] = value;
}
}
Notice that we use a require statement to ensure that the supplied index is within the range of our fixed-size array.
Running our test 100 times results in 12,541 gas units spent on average.
Now, let’s modify our array to be of fixed size 5:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
contract FixedArrayContract {
uint256[5] public fixedArray;
constructor() {
fixedArray = [1, 2, 3, 4, 5]; // Initialize the fixed-size array
}
function updateArray(uint256 index, uint256 value) public {
require(index < fixedArray.length, "Index out of range");
fixedArray[index] = value;
}
}
In this example, we define a fixed-size array of length 5 of type uint256
. The updateArray
function is the same as before, allowing us to update the value at a specific index in our array.
The EVM now knows the state variable fixedArray
is of size 5, and will allocate 5 slots to it without having to store its length in storage.
Running the same Foundry test 100 times and using a fixed array instead of a dynamic one, we save 17.99% gas.
Gas usage test using a fixed-size array in Solidity:
Before optimization: 12,541
After optimization: 10,284
Average savings: 17.99%
Test link on Github.
Using uint8
instead of uint256
in Solidity can be less efficient and potentially more costly in certain contexts, primarily due to how the EVM operates.
The EVM operates with a word size of 256 bits. Thus, operations on 256-bit integers (uint256
) are generally the most efficient as they align with the EVM's native word size.
When you use smaller integers like uint8
, Solidity often needs to perform additional operations to align these smaller types with the EVM's 256-bit word size. The result is more complex, less efficient code.
While using smaller types like uint8
can be beneficial for optimizing storage (since multiple uint8
variables can be packed into a single 256-bit storage slot), this benefit is typically seen only in storage, not in memory or stack operations.
Further, converting to and from uint256
for computations can negate the storage savings.
uint8 public a = 12;
uint256 public b = 13;
uint8 public c = 14;
// It can lead to inefficiencies and increased gas costs
// due to the EVM's optimization for 256-bit operations.
In summary, while using uint8
may seem like a good way to save space and potentially reduce costs, it can lead to inefficiencies and increased gas costs due to the EVM's optimization for 256-bit operations.
uint256 public a = 12;
uint256 public b = 14;
uint256 public c = 13;
// Better solution
You can create transactions that invoke a function f(uint8 x)
with a raw byte argument of 0xff000001
and 0x00000001
. Both are supplied to the contract and will appear as the number 1 to x. However, msg.data
will differ in each case. Therefore, if your code implements things like keccak256(msg.data)
running any logic, you will get different results.
As previously mentioned, using less than 256-bit int
or uint
variables is generally considered less efficient than 256 variables. However, there are situations where you’re forced to use smaller types, such as when using booleans that weigh 1 byte or 8-bit.
In these cases, by declaring state variables with the storage space in mind, Solidity will allow you to pack them and store them all in the same slot.
Note: The benefit of packing variables is typically seen only in storage and not in memory or stack operations.
Let’s consider the following example:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
contract NonPackedVariables {
bool public a = true;
uint256 public b = 14;
bool public c = false;
function setVariables(bool _a, uint256 _b, bool _c) public {
a = _a;
b = _b;
c = _c;
}
Considering what we’ve said before about each storage slot in Solidity having a space of 32 bytes (equal to 256-bit), in the example above, we have to use three storage slots to store our variables:
boolean
“a
” (1 byte)uint256
“b
”(32 bytes)boolean
“c
” (1 byte).Each storage slot used incurs a gas cost, hence we’re spending three times that cost.
Given that the combined size of the two boolean variables is 16 bits, which is 240 bits less than a single storage slot's capacity, we can instruct Solidity to store variables "a
" and "c
" in the same slot, AKA “we can pack them.”
Packing the variables together allows you to lower your deployment gas costs by reducing the number of slots required to store state variables.
We can pack these variables together by re-ordering their declarations as follows:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
contract PackedVariables {
bool public a = true;
bool public c = false;
uint256 public b = 14;
function setVariables(bool _a, bool _c, uint256 _b) public {
a = _a;
c = _c;
b = _b;
}
}
Re-ordering these variables allows Solidity to pack the two boolean variables together in the same slot since they weigh less than 256-bit (32 bytes).
However, we're still potentially wasting storage space. The EVM operates on 256-bit words and will have to perform operations to normalize smaller-sized words, which might offset any potential gas savings.
Running our Foundry test over 100 iterations we get an average optimization of 13% gas.
Gas usage test packing variables in Solidity:
Before optimization: 1,678
After optimization: 1,447
Average savings: 13%
Test link on Github.
In Solidity, choosing the most appropriate visibility for functions can be an effective way to optimize your smart contracts gas consumption. Specifically, using the external
visibility modifier can be more gas-efficient than public
.
The reason has to do with how public
functions handle arguments, and how the data is passed to these functions.
External
functions can read from calldata
, a read-only, temporary area in the EVM storing function call parameters. Using calldata
is more gas-efficient for external calls because it avoids copying data from the transaction data to memory.
On the other hand, public
functions can be called internally (from within the contract) and externally. When called externally, they behave similarly to external functions, with parameters passed in the transaction data. However, when called internally, the parameters are passed in memory
, not in calldata
.
Simply put, since public functions need to support both internal and external calls, they cannot be restricted to only accessing calldata.
Consider the following Solidity contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
contract PublicSum {
function calculateSum(
uint[] memory numbers
) public pure returns (uint sum) {
for (uint i = 0; i < numbers.length; i++) {
sum += numbers[i];
}
}
This function calculates the sum of an array of numbers. Because the function is public
, it has to accept an array from memory, which, if large, can be costly in terms of gas.
Now let's modify this function by making it external:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
contract ExternalSum {
function calculateSum(
uint[] calldata numbers
) external pure returns (uint sum) {
for (uint i = 0; i < numbers.length; i++) {
sum += numbers[i];
}
}
}
By changing the function to external
, we can now accept arrays from calldata
, making it more gas-efficient when dealing with large arrays.
This highlights the importance of properly using visibility modifiers in your Solidity smart contracts to optimize gas usage.
In this case, modifying your Solidity function modifiers will save, on average, 0.3% of gas units each call.
Gas usage test using external
modifier in Solidity:
Before optimization: 495,234
After optimization: 493,693
Average savings: 0.3%
Test link on Github.
When you identify multiple ways to reach the same functionality, it is worth reviewing the gas consumed by the related EVM opcodes to find the most gas-efficient approach.
An effective way to save gas is to avoid reading the same variable from storage multiple times. Reading from storage is more expensive than reading from memory. If you need to use a value multiple times, it is more gas-efficient to read it from storage once, cache it into memory, and then read it from memory all other times.
A lot of smart contract gas wastage is due to these two issues:
1. Re-reading the same value from storage repeatedly
2. Writing to storage unnecessarily.
In the following contract, the numbers array is stored in storage. Every time there is an iteration over the sumNumbers
function’s for
loop, the numbers
variable is read from storage.
contract ReadStorage {
uint256[] public numbers;
function addNumbers(uint256[] memory newNumbers) public {
for (uint256 i = 0; i < newNumbers.length; i++) {
numbers.push(newNumbers[i]);
}
}
function sumNumbers() public view returns (uint256) {
uint256 sum = 0;
for (uint256 i = 0; i < numbers.length; i++) {
sum += numbers[i];
}
return sum;
}
}
To avoid this, we can create a variable stored in memory at the start of the function, and assign it the value of the numbers
variable.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract CacheStorage {
uint256[] public numbers;
function addNumbers(uint256[] memory newNumbers) public {
for (uint256 i = 0; i < newNumbers.length; i++) {
numbers.push(newNumbers[i]);
}
}
function sumNumbers() public view returns (uint256) {
uint256 sum = 0;
uint256[] memory numbersArray = numbers;
for (uint256 i = 0; i < numbersArray.length; i++) {
sum += numbersArray[i];
}
return sum;
}
}
This reduces the sumNumbers function's gas consumption by 17%, but as the array grows, so will the number of iterations and the gas cost.
Gas usage caching variables in Solidity:
Before optimization: 3,527
After optimization: 2,905
Average savings: 17%
Test link on Github.
When you identify multiple ways to reach the same functionality, it is worth reviewing the gas consumed by the related EVM opcodes to find the most gas-efficient approach.
When we declare a state variable without initializing them (not assigning them an initial value), they are automatically initialized to their default values when the contract is deployed. The default values are:
0
for integersfalse
for booleansaddress(0)
for addressesThis costs less gas than declaring their value to be the default.
Let us compare the different declarations that produce the same results.
uint256 number; //this costs less
uint256 number = 0; //this costs more
bool claim; //this costs less
bool claim = false; //this costs more
address owner; //this costs less
address owner = address(0); //this costs more
It is common to see a state variable be assigned its default value and be immediately changed upon user interaction, which is not as gas efficient.
Let us look at two simple contracts with a state variable of type uint256
. In the second example, we do not initialize the variable, so it will be assigned its default value.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract WithoutInitializing {
uint256 counter = 0;
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract WithInitializing {
uint256 counter;
}
By not initializing the variables in our Solidity smart contract, we have optimized the average gas spent by 4%.
Gas usage test using a variables default value in Solidity:
Before optimization: 46,996
After optimization: 44,802
Average savings: 4%
Test link on Github.
Solidity comes equipped with a compiler with easy-to-modify settings to optimize your compiled code.
Consider the Solidity compiler as a wizard's spell book, and your intelligent manipulation of its options can create potions of optimization that significantly reduce gas usage.
The --optimize
option is one such spell you can cast.
When enabled, it performs several hundred runs, streamlining your bytecode and translating it into a leaner version that consumes less gas.
The compiler can then be adjusted to strike the proper balance between deployment and runtime costs.
For instance, using the --runs command lets you define the estimated number of executions for your contract.
solc --optimize --runs=200 GasOptimized.sol
By using the --optimize
flag and specifying --runs=200
, we instruct the compiler to optimize the code to reduce gas consumption during contract executions by running the incrementCount
function 200 times.
Adjust these settings to align with your application's unique needs.
When you compile a Solidity smart contract, the compiler transforms it into bytecodes, a series of EVM opcodes.
By using assembly, you can write code that operates at a level closely aligned with opcodes.
While it may not be the easiest task to write code at such a low level, the advantage lies in the ability to manually optimize the opcodes, thereby outperforming Solidity bytecode in certain scenarios.
This level of optimization allows for greater efficiency and effectiveness in contract execution.
In a simple example with two functions intended to add two numbers, one using plain solidity and the other one using assembly, we have small differences but the assembly one is still cheaper.
Solidity example:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
contract InefficientAddSolitiy {
// Standard Solidity function to add two numbers
function addSolidity(uint256 a, uint256 b) public pure returns (uint256) {
return a + b;
}
}
Now implementing Assembly:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
contract EfficientAddAssembly {
// Function using assembly to add two numbers
function addAssembly(
uint256 a,
uint256 b
) public pure returns (uint256 result) {
assembly {
result := add(a, b)
}
}
}
We want to make an honorific mention to Huff, which allows us to write Assembly with prettier syntax.
Note: Even if using Assembly might help you optimize the gas cost of your smart contracts, it might also lead to insecure code. We strongly recommend having your contracts reviewed by smart contract security experts before deploying.
Optimizing gas usage in Solidity is essential for creating cost-effective, high-performing, and sustainable Solidity smart contracts.
Deploying projects on L2s such as Base will reduce the cost users will incur to use your protocol, however, it is still your responsibility as the developer to implement the Solidity gas optimization tips you've learnt in this guide. These tips can dramatically reduce transaction costs, improve scalability, and enhance the overall efficiency of your contracts.