Developers always need to make sure they optimize smart contracts for gas usage. Contracts that aren't well optimized can end up using a lot of gas.
What is the problem with that?
Each unit of used gas costs Ether. The more gas a smart contract uses, the more it’ll cost the user to execute that contract. Plus it’ll slow down the entire network, waste computational resources and make the overall ecosystem less efficient.
In this article I want to share some tricks developers should remember to reduce the amount of gas they use to make smart contracts cheaper and more efficient.
This is a full list of techniques I am going to talk about:
Use calldata
instead of memory
Packing variables
Delete unused variables
Not shrinking variables unless you pack them together
Use events instead of storing data on chain
Use libraries
Use short-circuiting rules
Avoid unnecessary variable assignments
Avoid doing operations on storage
Use fixed size arrays
Use mapping instead of arrays
Use bytes type instead of string type
Use external modifier instead of public modifier if you can
In Solidity, variables can be stored in different locations. The location affects how the variable is accessed and used. The two most relevant locations for function parameters are memory
and calldata
.
How using calldata
instead of memory
helps reducing gas costs?
Memory
is a mutable location, which means it costs gas to read from and write to. When you declare a variable in memory, Solidity will allocate a space in the contract's memory to store the variable's value.
Calldata
is an immutable location, it is the data that is passed into a function from the function call itself. It is a read-only location, which means you can only read the data, not modify it.
Reading from calldata
is much cheaper than reading from memory
because you don't have to pay the gas cost of copying the data.
Let’s look at the example, it will be easier to understand.
The Test1
contract (optimized version) below uses a uint[] calldata
parameter. This means that the function will directly access the data passed in from the function call, without needing to copy the entire array into memory:
In a non-optimized version below (using memory) the function parameter numbers
is declared as uint[] memory
. This means that the entire array will be copied from the caller's memory into the function's memory, which costs gas:
The tradeoff here is that calldata
is read-only, so you can't modify the parameters vs memory
allows you to mutate the data. But in many cases, you don't even need to modify the function parameters so it will work.
In Solidity, when you compile and deploy a smart contract, a storage structure is created based on the order you declare your state variables. Each storage slot in Ethereum is 32 bytes.
1 slot = 32 bytes = 256 bits
By carefully ordering your variables, you can pack multiple smaller variables into a single storage slot and save gas as a result. For example, in the contract below the variables are efficiently packed:
The uint8
variables x
and y
together use only 16 bits, so they can share a slot with the uint128
variable w
, which uses 128 bits. The uint256
variable z
occupies its own slot.
So as a result we have 4 variables that occupy 2 slots. This decreases the gas costs for storage operations. In contrast, an unoptimized version below uses 4 full storage slots storing the same information:
When a variable is no longer needed, using the delete
keyword is more gas-efficient than manually resetting it to its default value.
Why is it more efficient ? The reason is in how the Ethereum Virtual Machine (EVM) handles storage operations.
The delete
keyword is optimized to clear storage slots, potentially triggering refunds for unused storage vs assigning a value, even if it's the default value, is treated as a regular SSTORE
operation, which costs gas.
For example in a function below the delete
operation resets x
to its default value of 0. This operation is gas-efficient:
The operation below is not gas-efficient because it explicitly setting x = 0:
You might think that using smaller variable types like uint8
instead of the default uint
(which is uint256
) leads to gas savings. But this is not true. Using the default uint
is generally more gas-efficient for standalone variables.
This is because Solidity needs to perform type conversion when working with smaller types, which needs additional gas costs.
For example, uint x = 10;
is more efficient than uint8 x = 10;
The EVM operates on 256-bit words, so using smaller types requires extra operations. The exception is when you have a bunch of smaller values and you can pack them. So use the default uint
for independent variables unless you're specifically packing multiple variables together in storage.
Another trick is to use events instead of storing data directly on the blockchain.
When you emit an event, you're essentially logging information that can be accessed off-chain, rather than storing it in the contract's state. This approach is cheaper. Writing to storage is one of the most expensive operations.
For example, a contract below is writing to storage. It’ll cost more gas to deploy and execute updateValue()
:
In contrast, a contract below uses events instead of storage. updateValue()
function emits this event instead of storing the value. It will cost less gas to deploy and execute updateValue()
:
Using libraries is particularly valuable in larger projects with multiple smart contracts.
When several contracts share similar functionality, replicating the same code across each contract leads to unnecessary deployment costs.
It also breaks DRY (Don't Repeat Yourself) principle. What you can do to reduce gas costs is to extract shared functionality into a library that can be accessed by all contracts.
The library approach is more gas-efficient because the library code is deployed only once and then reused by multiple contracts.
For example, below is an unoptimized version where doMath()
function is implemented in 3 separate contracts:
What you can do to make it optimized is to create a separate library contract that can be accessed by all contracts:
Since deployment costs are directly correlated with contract size, reducing duplicate code through libraries will lead to gas savings and it is also a great way for creating modular and maintainable contracts.
This trick takes advantage of how logical operators (&&
and ||
) evaluate conditions.
With the &&
(AND) operator, if the left side evaluates to false
, the right side won't be executed at all because the overall result must be false
.
Similarly, with the ||
(OR) operator, if the left side is true
, the right side won't be executed because the result must be true
.
Let’s look at the example below.
check1()
is more likely to return true
and is potentially simpler to compute than check2()
. The testOptimized()
function is more gas efficient because it puts check1()
first in the logical AND operation. If check1()
returns false
, the contract will skip executing check2()
entirely, saving gas.
So as a best practice, you should order your conditions by placing cheaper, more likely-to-short-circuit conditions first, followed by more expensive or less likely conditions.
In Solidity, when state variables are declared without an explicit assignment, they are automatically initialized to their default values (0 for uint
, false
for bool
, address(0) for address
, etc.).
The contract below is more gas-efficient because it leverages Solidity's default initialization behavior:
While the contract below unnecessarily spends gas to explicitly write the same default values to storage:
So it is cheaper to just leave an assignment blank instead of assigning it to zero.
Minimizing direct operations on storage variables is another trick for optimizing gas usage. Storage operations are one of the most expensive operations in terms of gas costs, so it's important to minimize them whenever possible.
Below there is the inefficient approach. Each iteration of the loop performs a storage operation, which is expensive in terms of gas:
Below is the optimized version.
It caches the storage variable in memory using a local variable (_count
), performs all operations on this memory variable, and only then updates the storage variable once at the end. This pattern (called storage caching) replaces multiple storage operations with a single one. to reduce gas costs.
When possible, use fixed-size arrays instead of dynamic arrays because they require less gas for storage operations. For example, if you know an array will always contain exactly 10 elements, using uint[10]
is more gas-efficient than uint[]
.
Mappings are generally more gas-efficient than arrays when you need to store and access key-value pairs, as they don't require iteration and have constant-time lookup.
For example, consider a simple user balance tracking system. Instead of saving userIds
and balances
in arrays like below:
Save them in a mapping data structure:
Using bytes
instead of string
is more gas-efficient when dealing with raw data, as string
requires additional encoding and decoding operations.
When you write functions in your smart contract, you can mark them as either public
or external
.
A public
function can be called by anyone: other contracts AND functions inside your contract. An external
function can only be called by other contracts or users, but NOT by functions inside your contract.
When you know the function will only be called from outside your contract use external
instead of public
because it's cheaper.