Categories: Gas Optimizations, Solidity,

Gas Optimization in Solidity Smart Contracts: A Developer’s Guide

Introduction

Every transaction on the Ethereum network comes with a cost—gas. The more efficient your smart contract is, the fewer gas users will have to pay to interact with it. Gas is the fee required to execute operations, whether they involve transferring ETH, interacting with a smart contract, or performing on-chain computations. The more complex the operation, the more gas it consumes. If your smart contract isn’t optimized, users may end up paying unnecessary fees, and in some cases, transactions might fail due to block gas limits.

With the rise of Layer 2 solutions like Polygon and Arbitrum, some might assume that gas optimization is less relevant. However, these solutions still operate on Ethereum’s foundation and incur costs. Gas fees on Layer 2 networks may be lower, but they aren’t negligible, especially as adoption increases and network demand grows. Additionally, Ethereum’s block gas limit, which typically hovers around 30 million gas units, imposes constraints on transaction execution. A poorly optimized contract could hit these limits, leading to failed transactions and wasted gas fees.

For developers, writing efficient Solidity code isn’t just about cost savings—it’s about usability. A contract that consumes excessive gas discourages user interaction, making it less attractive compared to more optimized alternatives. This guide explores practical ways to reduce gas costs in Solidity, ensuring your contracts run smoothly without breaking the bank.

Breaking Down Gas Costs in Solidity

1. Storage vs. Memory vs. Calldata

Solidity gives you three main ways to store data:

  • Storage: The most expensive option because data is permanently written to the blockchain.
  • Memory: A temporary, in-transaction space that gets wiped after execution.
  • Calldata: A cost effective, read-only data space primarily used for function arguments.

When passing arguments, use calldata instead of memory whenever possible to cut down on gas costs.

2. Expensive vs. Cheap Operations

Not all operations cost the same amount of gas. Some are way pricier than others:

  • Expensive: Writing to storage (sstore), deploying contracts, emitting events.
  • Moderate: Reading from storage (sload), calling external contracts.
  • Cheap: Simple math operations, reading from memory, using immutable variables.

Knowing which actions drain the most gas helps you write more efficient contracts.

Practical Gas Optimization Techniques

1. Optimize Variable Usage

Packing Variables to Save Space

Solidity stores data in 256-bit slots. If you use smaller types like uint128, you can fit two values in one slot, reducing gas costs. The Solidity compiler and optimizer handle packing automatically; you just need to declare packable variables consecutively in your contract.

contract OptimizedStorage {

uint128 a;

uint128 b; // Shares a storage slot with `a`

uint256 c; // Needs a separate slot

}

If c were placed between a and b, Solidity would use extra storage slots, unnecessarily increasing gas costs.

Using the Right Data Types

Solidity provides multiple integer sizes (uint8, uint16, uint256). While it might seem like using smaller types always saves gas, that’s only true when variables are packed. Otherwise, using uint256 is often more efficient since the EVM processes 256-bit words natively.

2. Optimizing Storage Access

Reduce Redundant Storage Writes

Writing to storage is one of the most expensive operations in Solidity. Instead of frequently modifying a stored variable, compute repeating values in memory and write the final result in storage only once.

contract GasSaver {

uint256 public total;

function add(uint256[] calldata values) external {

uint256 sum;

for (uint i = 0; i < values.length; i++) {

sum += values[i];

}

total = sum; // Single storage write

}

}

By computing the sum in memory first and writing it to storage only once, this approach significantly reduces gas costs.

Delete Unused Variables

Clearing storage variables when they’re no longer needed can earn you a gas refund.

delete myVariable; // Triggers a gas refund

3. Function-Level Gas Optimization

Use external Instead of public

Functions intended for external calls should be marked as external instead of public. This prevents Solidity from copying function arguments into memory, reducing gas costs.

contract EfficientFunctions {

function process(uint256 data) external returns (uint256) {

return data * 2;

}

}

Avoid Unnecessary Computations

If a value needs to be used multiple times within a function, store it in a local variable instead of recalculating it.

Inefficient (Unnecessary Recalculations)

function calculate(uint256 a, uint256 b) external pure returns (uint256) {

return (a * b) + (a * b) + (a * b);

}

In this case, the multiplication a * b is performed three times, increasing gas usage.

Optimized Example (Store the Computation Once)

function calculate(uint256 a, uint256 b) external pure returns (uint256) {

uint256 product = a * b;

return product + product + product;

}

Now, the multiplication happens only once, and the result is reused, saving gas.

4. Loop Efficiency and Iteration Optimization

Reducing Computational Complexity in Loops

Loops can be costly, especially when dealing with large arrays. Avoid unnecessary iterations by fetching an array’s length once instead of calling .length multiple times inside the loop.

function processArray(uint256[] calldata data) external {

uint256 length = data.length;

for (uint256 i = 0; i < length; i++) {

// Process data

}

}

Using unchecked to Skip Overflow Checks

Arithmetic operations like addition, subtraction, and multiplication typically include overflow checks to ensure that the result stays within the valid range of values for the data type (e.g., uint256). If an overflow happens, Solidity automatically reverts the transaction to prevent unexpected behavior.

However, these checks consume extra gas because the EVM needs to perform the comparison to ensure no overflow occurs. If you are confident that an operation won’t overflow (for example, when you know the numbers involved are small enough), you can use the unchecked keyword to skip these overflow checks, which can save gas.

Why is unchecked Gas Efficient?

  1. No Extra Comparison: By using unchecked, Solidity doesn’t need to perform the overflow check on every operation. The result is that it skips the computational overhead of verifying that the operation stays within bounds, leading to a slight reduction in gas consumption for those operations.
  2. Optimizing Simple Arithmetic: In some cases, your contract might involve operations where you know the values won’t overflow (e.g., adding two small numbers or performing operations on fixed-sized arrays). Using unchecked here allows you to make those operations cheaper, as no gas is spent on performing overflow checks.

Example:

Here’s an example of using unchecked in a simple addition operation:

pragma solidity ^0.8.0;

contract GasOptimization {

function safeAdd(uint256 a, uint256 b) public pure returns (uint256) {

return a + b; // Normal addition with overflow check

}

function uncheckedAdd(uint256 a, uint256 b) public pure returns (uint256) {

unchecked {

return a + b; // Addition without overflow check

}

}

}

In the uncheckedAdd function, the addition is done without checking if the result overflows. This skips the internal safety check and can save gas, especially when you’re sure the values won’t cause an overflow.

Be cautious when using unchecked. Skipping overflow checks means your contract might accept invalid operations if the numbers do overflow, potentially leading to unexpected behavior or security risks. Therefore, only use unchecked in situations where you’re certain no overflow can occur, and you’re comfortable with that risk.

5. Data Structure Selection for Gas Efficiency

Mappings vs. Arrays

Solidity is the first language I’ve worked with where mappings are actually cheaper than arrays! This comes down to how the EVM handles data storage—arrays aren’t stored sequentially in memory but function more like mappings. While you can optimize arrays by packing smaller data types (like uint8), mappings don’t offer that advantage.

That said, mappings lack built-in length properties and can’t be iterated over directly, so in some cases, you may have to use an array even if it costs more gas. The choice between arrays and mappings really depends on your specific use case. Use mappings for lookups when possible. They’re cheaper than arrays for finding and updating values.

Arrays are good for ordered data but can be expensive when modifying elements.

Struct Packing

Struct packing is an optimization technique in Solidity that helps reduce gas costs by efficiently utilizing storage slots. The EVM stores data in 32-byte (256-bit) slots, and when a struct is created, its variables are stored within these slots.

How Struct Packing Works

Each variable in a struct takes up space in storage, and the goal of struct packing is to ensure that multiple smaller variables can fit within a single 32-byte slot. If variables are arranged inefficiently, they can spill over into multiple slots, leading to higher gas costs.

Example of Poor Packing (More Expensive)

struct User {

uint256 balance; // Takes 32 bytes

uint128 rewards; // Starts a new slot (16 bytes)

uint256 level; // Takes another full slot (32 bytes)

}

Here, level starts a new slot even though there was unused space in the previous one.

Example of Optimized Struct Packing

struct User {

uint128 rewards; // Takes 16 bytes

uint128 level; // Fits in the same slot as `rewards`

uint256 balance; // Starts a new slot

}

By arranging rewards and level before balance, we make better use of storage, reducing the number of slots required.

6. Writing Efficient Code

Using Short Boolean Expressions

Logical operators like && and || stop evaluating as soon as the result is known. Take advantage of this to save gas.

if (x > 0 && y > 0) {

// If x is false, y is never checked

}

Custom Errors Instead of Strings

In Solidity, when you revert a transaction, you can provide an error message, usually a string, to help developers understand why the transaction failed. However, using strings in error messages can be quite costly in terms of gas.

The reason is that strings are dynamically sized in Solidity, meaning they can consume a lot of storage and memory, especially when they’re long. Every time a string is used, it has to be stored and then retrieved, which adds up in terms of gas costs.

Now, custom errors are a more gas-efficient alternative. Instead of using a string, you define a custom error with specific parameters that get passed when the transaction reverts. These errors are encoded much more efficiently, meaning less storage and fewer operations are required, ultimately saving gas.

For example:

// Custom error definition

error InsufficientBalance(address user, uint256 requested, uint256 available);

// Reverting with custom error

if (balance[msg.sender] < amount) {

revert InsufficientBalance(msg.sender, amount, balance[msg.sender]);

}

In this case, instead of using a string message like “Insufficient balance”, you’re using a custom error that packs the data more efficiently. This reduces the computational cost of reverting the transaction and makes your contract more gas-efficient. So, in short, using custom errors helps reduce the gas costs related to error handling in Solidity, making your smart contract more optimized for both performance and cost.

7. Advanced Gas Optimization Strategies

Using Inline Assembly

Inline assembly in Solidity allows developers to write low-level code directly within their smart contracts. It’s essentially like speaking directly to the EVM instead of using the higher-level Solidity language.

When it comes to gas optimization, inline assembly can be a powerful tool. This is because it lets you access more fine-grained control over how operations are performed, often in a more gas-efficient way than Solidity’s higher-level abstractions.

Here’s why:

  1. Direct EVM Operations: Solidity’s syntax is designed to be readable and safe, which means it sometimes adds extra steps or checks to ensure security. Inline assembly bypasses these higher-level protections and directly interacts with the EVM, allowing you to perform certain operations faster and with less overhead. For example, arithmetic operations or memory manipulation in Solidity may use more gas than necessary because of the abstractions, but in assembly, you can avoid unnecessary steps.
  2. Optimized Bytecode: Inline assembly lets you write more efficient bytecode. The Solidity compiler often adds extra code for things like checks and handling edge cases, but assembly lets you write exactly what you need—no more, no less. This can reduce the size of the deployed contract, saving gas during both deployment and execution.
  3. Low-Level Memory and Storage Access: When interacting with storage or memory, Solidity performs certain operations behind the scenes that are not always the most gas-efficient. Inline assembly lets you directly manipulate memory and storage, potentially saving gas when accessing or modifying data. For instance, using inline assembly to directly write to memory can be cheaper than using Solidity’s higher-level storage operations.

Example:

function addNumbers(uint256 a, uint256 b) public pure returns (uint256 result) {

assembly {

result := add(a, b)

}

}

In this example, the add operation is written in inline assembly, which is more efficient than Solidity’s high-level function call for addition. This simple example may not show a big difference, but in more complex functions, assembly can reduce the gas cost significantly.

However, a word of caution: Inline assembly is powerful but can be tricky to use correctly. It’s easy to make mistakes, and because it bypasses some of Solidity’s built-in safety checks, it could introduce vulnerabilities. It’s recommended to use assembly sparingly and only when you’re confident that the optimization is significant enough to justify the added complexity.

Using Bitwise Operations

Bitwise operations are another great tool for gas optimization. These operations allow you to directly manipulate individual bits of data (the 0s and 1s that make up values in memory). By using bitwise operations, you can perform certain tasks more efficiently, saving on gas costs when dealing with certain types of data.

Why are Bitwise Operations Gas Efficient?

  1. Lower Computational Cost: Bitwise operations are very low-level operations, meaning they don’t require as much computational overhead as high-level arithmetic or conditional checks.
  2. Compact Data Storage: Bitwise operations can help you pack multiple values into a single storage slot or a single variable. For example, if you need to store several small pieces of information (like boolean flags or small integers), you can pack them together in a single number using bitwise shifts and masks. This reduces the number of variables or storage slots you need, saving gas on storage operations.
  3. Efficient Flags and Masking: When you’re dealing with boolean flags or certain types of status codes, bitwise operations can combine or extract these flags very efficiently.

Example: Using Bitwise AND, OR, and Shifting

Let’s say you want to store several flags in a single uint256 variable. Each flag could represent a different condition or state in your contract, and instead of having a separate boolean variable for each one, you could pack them into a single integer.

// Example of setting flags using bitwise operations

contract FlagStorage {

uint256 flags;

// Setting a flag (bit 0)

function setFlag(uint256 flag) public {

flags |= (1 << flag); // Set the bit corresponding to the flag

}

// Checking a flag (bit 0)

function checkFlag(uint256 flag) public view returns (bool) {

return (flags & (1 << flag)) != 0; // Check if the bit is set

}

// Resetting a flag (bit 0)

function resetFlag(uint256 flag) public {

flags &= ~(1 << flag); // Clear the bit corresponding to the flag

}

}

Here’s how it works:
  • The setFlag function sets the bit at a given position (corresponding to the flag) using the bitwise OR (|) and a left shift (<<).
  • The checkFlag function checks if a specific flag is set by performing a bitwise AND (&) with a shifted 1. If the result is not zero, the flag is set.
  • The resetFlag function clears a specific flag by using the bitwise AND (&) and a negated left shift (~(1 << flag)).

By packing multiple flags into a single integer, you reduce the amount of storage needed. Instead of having several boolean variables (each taking up a storage slot), you’re using just one uint256 to hold multiple flags. This is not only more gas-efficient but also more space-efficient on the blockchain.

Conclusion

Optimizing gas in Solidity comes down to making thoughtful decisions. It’s about minimizing the number of storage writes, organizing loops to be efficient, and choosing the right data types for the job. Even small adjustments can lead to significant savings in gas costs over time, both during deployment and when users interact with your contract.

As Solidity and Ethereum evolve, it’s important to keep up with new updates and best practices. This ensures your contracts stay lean and cost-effective, without missing out on new features that could help improve performance. Ultimately, optimizing gas is about balancing efficiency with functionality, so your smart contracts can do more with less.

Recent Blogs

Gas Optimization in Solidity Smart Contracts: A Developer’s Guide

Introduction Every transaction on the Ethereum network comes with a […]

Read More

Role of Unit Testing in Smart Contract Security

Introduction Smart contracts are self-executing programs that handle critical financial and operational transactions

Read More

EIP-1153: Transient storage

Understanding Transient Storage in Ethereum Definition and Conceptualization: Transient storage represents an innovative,

Read More

Leading the Wave of Web3 Security

REQUEST AUDIT

STAY AHEAD OF THE SECURITY CURVE.