
Solidity Gas Optimization: 12 Techniques to Make Your Smart Contracts Cheaper and More Efficient
Written by Usman Asim

Ethereum gas fees have been a historical painpoint for the ecosystem: what user wants to pay $20+ in gas for a simple onchain transaction? While new Ethereum upgrades in the past couple years have brought gas costs down substantially for users, optimizing gas in your Solidity code is still the main way to enable complex onchain actions in your app without breaking the bank for your users.
Whether you're minimizing risks in code reviews or just writing cleaner contracts, getting gas optimization right means enabling secure, affordable apps that scale to millions of users. In this guide, we'll break down gas basics, why optimization matters (spoiler: it can cut costs by 20-50% compared to unoptimized code), and share 12 optimization techniques with code examples.
What Is Gas and Gas Optimization in Solidity?
Gas is the unit of measurement for the amount of computational effort required to carry out specific operations on Ethereum, and Solidity gas optimization is the process of making your Solidity smart code less expensive to execute.
When transacting on Ethereum, every transaction comes with a cost, the cost of writing data to storage or processing a transaction, and that cost is fittingly called “gas.” If you don’t pay gas fees, nothing happens, just like a car requires gas to move.
You need that gas for contracts too. Here's what happens when you deploy and execute a smart contract:
You write Solidity code. This is the high-level, human readable programming language for Ethereum smart contracts.
The compiler converts it to bytecode. When you compile your Solidity code, the compiler translates it into bytecode, a hexadecimal representation of low-level instructions. This bytecode is what actually gets stored on the blockchain.
Bytecode contains opcodes. That bytecode is made up of opcodes (operation codes), which are the EVM's instruction set: think of them like assembly language for Ethereum. Each opcode represents a specific operation like "add two numbers," "read from storage," or "jump to another instruction."
The EVM executes opcodes. When someone calls your smart contract, the Ethereum Virtual Machine (EVM) running on nodes across the network reads the bytecode, decodes it into individual opcodes, and executes them one by one. Each opcode has a fixed gas cost.
For example, the ADD opcode (which adds two numbers) costs 3 gas, while SSTORE (which writes to storage) costs at least 20,000 gas. The EVM tallies up the gas cost of every opcode it executes during your transaction.
For more on Ethereum's gas model, check the official docs.
Gas optimization is tweaking your code to do the same job, but with fewer operations, decreasing execution costs. Like we mentioned above, every transaction needs gas (paid in ETH or equivalents on different chains). Post Dencun, data availability is cheaper thanks to blobs, but execution gas (the compute costs) can still add up. Optimized contracts not only save users money, but they also guard against DoS attacks by being leaner codebases. Dive deeper into opcodes in the "Gas Optimizer" docs.
Why Is Gas Optimization Important to Developers?
High gas = frustrating UX that could cause users to stop using your app, especially during traffic spikes where gas fees can surge.
Optimizing your code for low gas usage can decrease fees for your users, offer a better UX and enable low-value transactions that would otherwise be uneconomical due to fees, and handle high usage without hitting block limits.
Unoptimized contracts can burn can burn 20-50% extra gas, hiking costs and opening exploit doors. With DeFi TVL hitting nearly $150 billion as of October 2025, gas efficient contracts aren't just nice to have, they're a competitive advantage that can make or break user adoption.
Either be the app with complex smart contract logic that causes users to pay the high fees associated with that complexity, or optimize your code to make the end experience of your users better, and cheaper.
Top 12 Solidity Gas Optimization Techniques
Here are battle tested ways to optimize gas usage in your code. We'll explain each example, how it saves on gas, and show the code. We encourage you to test these in Remix or Hardhat to see the differences yourself.
1. Use mappings instead of arrays
Solidity offers two main data structures for storing lists of data: arrays and mappings. While their syntax looks similar, they serve very different purposes and have drastically different gas costs.
Arrays are ordered, iterable collections that store elements sequentially in memory or storage. They're useful when you need to loop through all items or maintain a specific order. However, finding a specific item in an array requires iteration: the EVM must check each element until it finds a match. This means lookup operations are O(n) complexity, getting more expensive as your array grows.
Mappings (also called hash tables) work completely differently. They use a key-value structure where you can instantly retrieve any value by its key, with O(1) constant-time lookups regardless of how many items are stored. This happens because Solidity computes the storage slot directly from the key using a hash function, eliminating the need to search through data. The trade-off is that mappings aren't iterable, you can't loop through all entries or know what keys exist without tracking them separately.
The gas impact: Creating and accessing mapping entries is significantly cheaper than array operations because there's no iteration overhead. Use arrays only when you specifically need to iterate through all items or maintain insertion order. For all other cases, especially user balances, ownership records, or any key-based lookups: mappings are the clear winner.
Here's an example using an array (more expensive for access):
string[] public cars = ["ford", "audi", "chevrolet"];
// To find "audi", you'd need to loop through the array
function findCar(string memory target) public view returns (bool) {
for (uint i = 0; i < cars.length; i++) {
if (keccak256(bytes(cars[i])) == keccak256(bytes(target))) {
return true; // Cost increases with array size
}
}
return false;
}Here's the same data using a mapping (much cheaper for lookups):
mapping(uint => string) public cars;
constructor() {
cars[101] = "Ford";
cars[102] = "Audi";
cars[103] = "Chevrolet";
}
// Direct access - O(1) constant time regardless of data size
function getCar(uint id) public view returns (string memory) {
return cars[id]; // Single storage read, minimal gas
}Using integer keys lets you mimic ordered lists without the iteration costs of arrays. This is especially powerful for user data like balances or ownership records where you need fast, direct access by ID. For more on how mappings work under the hood, see the Solidity mappings documentation.
2. Enable the Solidity compiler optimizer
The Solidity compiler optimizer is a powerful tool that can significantly reduce gas costs, but it requires configuration to match your specific use case. The optimizer works by analyzing your code and applying various transformations: it simplifies expressions, removes dead code, inlines small functions to eliminate expensive jump operations, and reuses duplicate code segments. All of these changes reduce the number of opcodes the EVM needs to execute.
However, there's an important trade off controlled by the "runs" parameter. This number tells the optimizer how many times you expect each opcode in your contract to be executed over its lifetime. The optimizer uses this to balance two competing goals: minimizing deployment costs (which happen once) versus minimizing runtime execution costs (which happen repeatedly with every function call).
How the runs parameter works:
Low runs (e.g., 200): The optimizer prioritizes smaller bytecode, resulting in cheaper deployment. This means less code duplication and more jumps between reusable code segments. Best for contracts you'll deploy frequently but call infrequently—like factory contracts or one-time-use deployment scripts.
High runs (e.g., 10,000+): The optimizer prioritizes runtime efficiency by duplicating code to avoid jumps and heavily inlining functions. This creates larger bytecode (more expensive deployment) but faster, cheaper function execution. Best for contracts with high transaction volume: like DEX routers, staking contracts, or NFT marketplaces.
Here’s an example for deploy-heavy apps with low runs (200):
module.exports = { solidity: { version: "0.8.9", settings: { optimizer: { enabled: true, // Set to true for opt runs: 200, }, }, },};And here’s an example for deploy-light apps that are optimized for high runs (10000):
module.exports = { solidity: { version: "0.8.9", settings: { optimizer: { enabled: true, runs: 10000, }, }, },};Choosing the right runs value
There are tradeoffs to either approach. Think about your contract's lifecycle. A governance token that gets deployed once but has millions of transfer transactions should use high runs. A deployment factory that creates new contracts constantly but those contracts are rarely called should use low runs. When in doubt, 200 is a safe default that balances both concerns reasonably well. Feel free to test with your contract's profile, and you can enable either in Hardhat config or Remix.
3. Minimize on-chain data
On chain storage is the single most expensive operation in Solidity. Each SSTORE opcode (writing to storage) can cost 20,000+ gas (measured in Gwei), and modifying existing storage slots still runs 2,900-5,000 gas. Compare that to memory operations which cost just 3 gas, and you quickly see why storage optimization is critical. The fundamental principle here is simple: store only what's absolutely essential on chain, and handle everything else off chain through APIs, oracles, or indexing services.
Beyond just reducing storage writes, you should also smartly group operations to avoid redundant costs. This keeps your contract lean, reducing both deployment and runtime fees while making it harder for attackers to exploit computationally heavy functions that could be used in DoS attacks.
Saving data in storage variables
Be ruthless about what deserves storage. For example, user balances, ownership records, and contract state that must be tamperproof and globally accessible: these belong on chain. If optimizing for gas, everything else should live off chain. For example, if you need external price data, use oracles like Chainlink to fetch it during execution rather than storing historical prices. If you need to track transaction history for a frontend, emit events instead of storing arrays.
A key gotcha with events: While events are cheap to emit (around 375 gas base + 375 gas per topic), contracts cannot read their own events. Events exist purely for off chain consumption by indexers and frontends. Never use events as a substitute for storage that your contract logic needs to access.
Batching operations
Instead of requiring users to submit multiple separate transactions, bundle related actions into a single transaction. This saves the 21,000 gas base transaction fee (paid for every transaction regardless of what it does) and also reduces redundant operations like checking msg.sender multiple times, loading the same storage variables repeatedly, or paying calldata costs for multiple transaction submissions.
This pattern is especially powerful for multi step processes like token approvals followed by transfers, or executing multiple DeFi operations atomically (swap, then stake, then claim rewards).
Here's an example of a batch send function:
Struct Call {
address recipient;
uint256 gas;
uint256 value;
bytes data;
}
function batchSend(Call[] memory _calls) public payable {
for(uint256 i = 0; i < _calls.length; i++) {
(bool _success, bytes memory _data) = _calls[i].recipient.call{
gas: _calls[i].gas,
value: _calls[i].value
}(_calls[i].data);
if (!_success) {
assembly {
revert(add(0x20, _data), mload(_data))
}
}
}
}This pattern saves significantly on gas by eliminating repeated msg.sender verifications, reducing calldata overhead (you only pass the function selector once), and paying the base transaction fee just one time instead of once per operation.
Looping
Loops are gas multipliers, every iteration repeats the same operations, and costs stack up linearly. A loop over 100 items doing storage operations could easily consume 500,000+ gas, and loops over unbounded arrays can even exceed block gas limits, making your function permanently uncallable.
The solution is almost always to eliminate the loop entirely. Use mappings for O(1) constant-time lookups instead of O(n) array iteration. If you absolutely must iterate, limit array sizes strictly, or better yet, move the iteration off-chain and have users submit specific indices or keys.
A note on gas refunds
Solidity used to offer gas refunds for clearing storage (setting values to zero), but EIP-3529 significantly reduced these refunds. While you still get a small refund for clearing storage, it's no longer a major optimization strategy. For details on current refund mechanics, see Ethereum gas refunds proposal.
4. Use indexed events
Events are a lightweight logging mechanism that cost a fraction of storage operations, around 375 gas base plus 375 gas per indexed parameter, compared to 20,000+ gas for writing to storage. Events get written to the transaction receipt trie, which is separate from contract state storage, making them perfect for recording information that off chain applications need to track.
The critical limitation: events are write-only from the contract's perspective. Once emitted, your contract code cannot read them back. Events exist purely for external consumption by frontends, indexers, and monitoring tools. Use events for notifications and historical records that off-chain systems need, but never for data your contract logic depends on.
This offloads massive amounts of gas. Instead of storing every transaction in an expensive storage array, emit an event and let off-chain indexers (like The Graph or Alchemy's APIs) build that history for your frontend.
Here's how to declare and emit an event:
event MyFirstEvent(address indexed sender, uint256 indexed amount, string message);
function doSomething(uint256 _amount, string memory _message) public {
// Your contract logic here
// Emit the event - cheap logging instead of expensive storage
emit MyFirstEvent(msg.sender, _amount, _message);
}Indexed parameters
You can mark up to 3 parameters as indexed, which makes them searchable in log queries. For example, with sender and amount indexed, you can quickly filter "all events where sender = 0x123..." without scanning every event. Non-indexed parameters like message still get logged but aren't directly searchable.
Common use cases include token transfers, ownership changes, state transitions, and user activity tracking, anywhere you need a record for off-chain consumption but don't need on-chain access. For more details, check the Solidity events documentation.
5. Pack your variables
The EVM stores data in 32-byte slots, and each storage slot costs gas to write (20,000+ gas for new slots, 2,900-5,000 gas for updates). By strategically grouping small variables together, you can fit multiple variables into a single slot, dramatically reducing the number of SSTORE operations your contract needs.
Think of it like packing a suitcase efficiently: the order you arrange items determines how much space you waste. Variables are packed in the order you declare them, so careful arrangement is crucial.
Before (wastes space across 3 slots):
contract MyContract {
uint128 c; // Slot 0 (uses 16 bytes, wastes 16 bytes)
uint256 b; // Slot 1 (uses full 32 bytes)
uint128 a; // Slot 2 (uses 16 bytes, wastes 16 bytes)
}This uses 3 storage slots even though the data only needs 2.5 slots worth of space.
After (compacts into 2 slots):
contract MyContract {
uint128 a; // Slot 0 (first 16 bytes)
uint128 c; // Slot 0 (second 16 bytes) - packed together!
uint256 b; // Slot 1 (full 32 bytes)
}By declaring the two uint128 variables consecutively, they share a single slot, saving an entire SSTORE operation every time you write to both variables.
Key packing rules:
Variables are packed in declaration order
A new slot starts when the next variable doesn't fit in the current slot
Smaller types like
uint8,uint128,address(20 bytes) are perfect candidates for packingEven if a small type stands alone, it still consumes a full 32-byte slot, so always try to pair them
For example, two address variables (20 bytes each) won't pack into one slot since 40 bytes exceeds 32 bytes. But an address (20 bytes) plus a uint96 (12 bytes) fits perfectly into one 32-byte slot.
For more details on how Solidity arranges storage, check the storage layout documentation.
6. Free up unused storage
When you clear storage variables by setting them back to their default values (0 for integers, address(0) for addresses, false for booleans), the EVM provides a gas refund. While EIP-3529 reduced these refunds significantly from their original amounts, you still get back 4,800 gas per storage slot cleared: a meaningful recovery when cleaning up obsolete data.
This works because resetting storage slots reduces the blockchain's state size, so Ethereum incentivizes this cleanup behavior. It's a way to reclaim some costs when data becomes obsolete while keeping your contract state lean and efficient.
Here's how to clear variables:
delete myVariable; // Resets to default value and triggers refund// Or explicitly:
myInt = 0;
myAddress = address(0);
myBool = false;Important note on mappings: The delete keyword doesn't work on entire mappings because mappings don't track which keys exist. Instead, you must delete individual mapping entries:
mapping(address => uint256) public balances;
// This won't work - can't delete entire mapping// delete balances;// Instead, delete specific keys:
delete balances[msg.sender]; // Clears this specific entryPractical use cases
Gas refunds are most useful in contracts where data has a clear lifecycle, think escrow contracts that can be cleaned up after completion, temporary authorizations that expire, or cached data that becomes stale. Don't contort your contract logic just to chase refunds, but when data naturally becomes obsolete, cleaning it up is a win-win.
For current refund mechanics and limitations, check the EIP-3529 gas refunds documentation.
7. Store data in calldata instead of memory for certain function parameters
When declaring function parameters, you have a choice between memory and calldata for reference types like arrays, strings, and structs. Understanding the difference can save significant gas, especially for external functions with large parameters.
Calldata is read-only storage that lives directly in the transaction data. When you use calldata, the function reads arguments directly from the transaction without copying them anywhere. This is the cheapest option because it avoids memory allocation and copy operations entirely.
Memory, on the other hand, requires the EVM to allocate space and copy the data from calldata into memory, executing multiple MLOAD and MSTORE operations. This copying overhead becomes expensive with large arrays or strings: each element copied costs additional gas.
The rule: Use calldata for external function parameters you only need to read. Use memory only when you need to modify the data within your function.
Here's an example using calldata (cheaper for read-only access):
function processNumbers(uint[] calldata nums) external {
for (uint i = 0; i < nums.length; i++) {
// Read-only operations - no copy needed
uint value = nums[i];
// Do something with value
}
}Compare to memory (more expensive due to copying):
function processNumbers(uint[] memory nums) external {
// Data gets copied from calldata to memory first (costs gas)
for (uint i = 0; i < nums.length; i++) {
uint value = nums[i];
}
}The gas savings scale with data size, a 100-element array passed as calldata can save thousands of gas compared to memory.
When you must use memory: If your function needs to modify the array, append to it, or build new data structures, then memory is necessary since calldata is immutable. But for pure read operations, calldata is always the better choice.
For more on the differences between storage locations, see calldata vs memory documentation.
8. Use immutable and constant fixed values
Variables marked as constant or immutable don't use storage slots at all: their values get baked directly into the contract's bytecode at deployment. This eliminates expensive SLOAD operations (2,100 gas each) every time you access them, replacing storage reads with cheap bytecode reads.
Constant: Value must be set at compile time and cannot change. Use for hardcoded values that will never vary across deployments.
Immutable: Value is set once in the constructor and cannot change afterward. Use for values that differ between deployments (like token addresses or owner addresses) but remain fixed once deployed.
Here's how to use both:
contract MyContract {
uint256 constant FEE_RATE = 10; // Set at compile time
address immutable owner; // Set once at deployment
constructor(address _owner) {
owner = _owner; // Can only set in constructor
}
function calculateFee(uint256 amount) public pure returns (uint256) {
return amount * FEE_RATE / 100; // No SLOAD - reads from bytecode
}
}Gas savings example: If you read a normal storage variable 10 times in a function, that's 21,000 gas in SLOAD operations. With constant or immutable, those reads cost essentially nothing: just the gas to execute basic arithmetic operations.
Common use cases:
Protocol fee rates or percentages (
constant)Mathematical constants like decimals or scaling factors (
constant)Token addresses from constructor arguments (
immutable)Contract owner or admin addresses (
immutable)External contract addresses that won't change (
immutable)
Both constant and immutable variables can also be declared at the file level outside of contracts, making them reusable across multiple contracts in the same file.
For more details, see the constants and immutables documentation.
9. Use the external visibility modifier
Function visibility modifiers affect how the EVM handles function calls and can impact gas costs. The key difference: external functions are optimized specifically for calls from outside the contract, while public functions must handle both external and internal calls, adding overhead.
External functions can only be called from outside the contract (via transactions or other contracts). When called externally, they read parameters directly from calldata without copying, making them slightly more efficient. You cannot call an external function internally using functionName()you'd need to use this.functionName(), which creates an expensive external call.
Public functions can be called both externally and internally. This flexibility requires the compiler to generate additional code to handle both call types, adding a small gas overhead even when called externally.
A general rule of thumb: Use external for functions that are part of your contract's public API and will only be called by users or other contracts. Use internal or private for helper functions that only your contract's own functions need to call.
Here's an example of an external function (optimized for outside calls):
function updateMessage(string calldata _newMessage) external returns (string memory) {
message = _newMessage;
return message;
}Compare to public (handles both internal and external):
function updateMessage(string memory _newMessage) public returns (string memory) {
message = _newMessage;
return message;
}The gas savings are modest: typically around 20-50 gas per call, but they add up over thousands of transactions. More importantly, using external clearly signals intent: this function is meant to be called from outside the contract.
Bonus tip: Notice how the external version uses calldata for the string parameter? External functions pair perfectly with calldata arguments since both are optimized for external calls.
For more on function visibility and best practices, see the visibility documentation.
10. Use unchecked arithmetic safely
Starting with Solidity 0.8.0, the compiler automatically adds overflow and underflow checks to all arithmetic operations. While this prevents bugs, it costs roughly 30-40 gas per operation. When you're certain that overflow/underflow is mathematically impossible, wrap operations in unchecked blocks to skip these checks and save gas.
When it's safe: Loop counters with known bounds, arithmetic where you've validated inputs, or operations where overflow is mathematically impossible.
When to avoid: User-supplied values without validation, financial calculations, or anywhere overflow could create vulnerabilities.
Here's a basic example:
function add(uint x, uint y) external pure returns (uint) {
unchecked {
return x + y; // Skips overflow check - saves ~30 gas
}
}Here's another example showing loop counters, the most common use case for this technique:
function processArray(uint[] calldata items) external {
for (uint i = 0; i < items.length;) {
// Process item
unchecked {
++i; // i can never realistically overflow
}
}
}In this loop, i starts at 0 and increments by 1 each iteration. For i to overflow, the array would need 2^256 elements, which is physically impossible given blockchain constraints. This is a perfect candidate for unchecked.
Important safety note: If you use unchecked, add manual require statements to validate inputs that could cause issues:
function subtract(uint x, uint y) external pure returns (uint) {
require(x >= y, "Underflow prevented");
unchecked {
return x - y; // Safe because validated above
}
}The gas savings from unchecked blocks accumulate quickly in loops or functions called frequently. For more details and edge cases, see the unchecked documentation.
11. Minimize external calls
Every call to another contract costs at least 100 gas for the CALL opcode, plus additional costs for calldata and any state changes in the called contract. These calls can also fail unpredictably if the external contract reverts, making them both expensive and risky. When you need data from external contracts, batch multiple calls together or cache results to avoid repeated calls within the same transaction.
Gas-efficient approach:
interface IExternal {
function getData() external view returns (uint);
}
function processData(address addr) external {
// Call once and cache the result
uint data = IExternal(addr).getData();
// Reuse cached value multiple times - no additional calls
uint result1 = data * 2;
uint result2 = data + 100;
uint result3 = data / 5;
}Inefficient approach (avoid this):
function processData(address addr) external {
// Calling three separate times - wastes ~300 gas
uint result1 = IExternal(addr).getData() * 2;
uint result2 = IExternal(addr).getData() + 100;
uint result3 = IExternal(addr).getData() / 5;
}If you need the same data multiple times in a transaction, always cache it in a local variable. If you need external data across multiple transactions, consider storing it (though weigh the 20,000 gas SSTORE cost against the frequency of external calls).
For patterns and security considerations around external calls, see the external calls best practices.
12. Use assembly for critical paths
For performance critical code like tight loops or frequently called functions, you can drop down to Yul assembly to manually optimize beyond what the Solidity compiler can achieve. Assembly gives you direct control over memory management, lets you skip safety checks, and eliminates abstraction overhead. However, it's a double-edged sword—assembly bypasses all of Solidity's safety features, making code harder to read and extremely error-prone.
When to consider assembly: High-frequency operations, complex bit manipulation, custom memory layouts, or loops that execute thousands of times where every gas unit counts.
When to avoid assembly: Anywhere else. The gas savings rarely justify the increased risk of bugs, security vulnerabilities, and maintenance burden.
Here's an example of summing an array using assembly:
function sum(uint[] memory arr) public pure returns (uint s) {
assembly {
let len := mload(arr) // Load array length
let data := add(arr, 0x20) // Skip length, point to data
for { let i := 0 } lt(i, len) { i := add(i, 1) } {
s := add(s, mload(add(data, mul(i, 0x20)))) // Load and sum each element
}
}
}This assembly version saves gas by directly manipulating memory pointers and skipping bounds checks, but it's significantly harder to understand and audit compared to equivalent Solidity code.
Critical safety practices
Thoroughly test assembly code with edge cases
Add extensive comments explaining every operation
Have assembly sections audited by security experts
Use assembly only as a last resort after exhausting Solidity optimizations
For most developers and most use cases, the other 11 techniques in this guide will provide better gas savings with far less risk. Only reach for assembly when you've profiled your contract, identified specific bottlenecks, and confirmed the gas savings justify the added complexity.
For learning Yul syntax and capabilities, see the Yul documentation.
Test Your Smart Contract Before Deployment
Before deploying to mainnet, rigorously test your gas usage using development tools. Hardhat provides gas reporter plugins that generate detailed breakdowns of gas costs per function. Remix IDE shows gas estimates in real time as you test. Focus your optimization efforts on user-facing functions like mints, transfers, and swaps: these are called most frequently and have the biggest impact on user experience.
For deeper testing, use Foundry's fuzzing capabilities to benchmark your optimizations across thousands of randomized inputs, ensuring your gas savings hold up under real world conditions and edge cases.
Wrapping Up: Get Optimizing
You now have 12 battle-tested techniques to make your Solidity contracts leaner and cheaper to execute. Gas optimization isn't just about saving money: it's about building better experiences for your users and creating applications they actually want to interact with.
Start by implementing the low hanging fruit: enable the compiler optimizer, use mappings instead of arrays, and mark fixed values as constant or immutable. Then profile your contracts to find bottlenecks and apply the more advanced techniques where they'll have the most impact.
Resources for continued learning:
Now get out there and start optimizing, your users (and their wallets) will thank you!

Related overviews
Secure your smart contracts with these expert security tips and tools.
What it is, How it Works, and How to Get Started
Explore the Best Free and Paid Courses for Learning Solidity Development

Build blockchain magic
Alchemy combines the most powerful web3 developer products and tools with resources, community and legendary support.