Gas and Gas Price in Solidity
A practical guide explaining how Ethereum's gas mechanism works, why it exists, and how to write cost-efficient smart contracts through proper variable packing and gas limit management.
Published on 01 November, 2025
Categories:
SolidityWhat is Gas?
Gas is the unit that measures the computational effort required to execute operations on the Ethereum network. Every operation you perform in a smart contract costs a certain amount of gas. Think of it like fuel for your car - the more complex the journey, the more fuel you need.
When you deploy a contract or call a function, you're asking the network's nodes to execute your code. These nodes need compensation for their computational work, which is why gas exists.
Why Does Gas Matter?
Gas directly translates to real money. Users pay for gas in ETH, and if your smart contract is inefficient, it becomes expensive to use. A poorly optimized contract can cost users hundreds of dollars in transaction fees during network congestion.
Here's the basic math:
Transaction Cost = Gas Used × Gas Price
Gas Price is measured in gwei (1 gwei = 0.000000001 ETH). During peak times, gas prices can spike from 20 gwei to 200+ gwei, making the same operation 10x more expensive.
How Gas Works in Practice
Let's look at a simple contract to understand gas consumption:
contract GasExample { uint public counter = 0; function increment() public { counter += 1; // Costs ~5000 gas } function reset() public { counter = 0; // Costs ~5000 gas } }
When you call
increment(), the EVM calculates gas based on:- Reading from storage: 2100 gas
- Writing to storage: 20000 gas for new value, 5000 gas for updating
- Basic computation: 3 gas per operation
Storage operations are the most expensive. Reading costs 2100 gas, writing costs 5000-20000 gas depending on whether you're updating or initializing.
Understanding Gas Limit in Detail
Gas limit is the maximum amount of gas you're willing to spend on a transaction. It serves as a safety mechanism to prevent infinite loops and protect you from spending unlimited ETH.
Block Gas Limit vs Transaction Gas Limit
There are two types of gas limits you need to understand:
Block Gas Limit: The maximum gas allowed in a single block. Currently around 30 million gas on Ethereum mainnet. This is set by miners/validators and determines how many transactions fit in one block.
Transaction Gas Limit: The maximum gas you set for your specific transaction. This is what you control when sending a transaction.
contract GasLimitExample { uint[] public numbers; // This function's gas cost depends on array size function addNumbers(uint count) public { for(uint i = 0; i < count; i++) { numbers.push(i); // Each iteration costs gas } } }
If you call
addNumbers(1000) with a gas limit of 100000, but it actually needs 500000 gas, the transaction fails. The EVM stops execution, reverts all state changes, but you still pay for the gas consumed up to the limit.How Gas Limit Works
Here's what happens during transaction execution:
- You set a gas limit (example: 100000 gas)
- You set a gas price (example: 50 gwei)
- Your wallet reserves: 100000 × 50 = 5000000 gwei (0.005 ETH)
- Transaction executes and uses actual gas (example: 65000)
- You pay: 65000 × 50 = 3250000 gwei (0.00325 ETH)
- Unused gas (35000) gets refunded
contract UnderstandingLimits { mapping(address => uint) public data; function safeOperation() public { // Simple operation, needs ~50000 gas data[msg.sender] = block.timestamp; } function riskyOperation(uint iterations) public { // Gas cost grows with iterations // 100 iterations might need 2000000 gas for(uint i = 0; i < iterations; i++) { data[address(uint160(i))] = i; } } }
Setting Appropriate Gas Limits
For
safeOperation() above, setting 100000 gas limit is safe. For riskyOperation(100), you'd need much higher, perhaps 3000000.Most wallets estimate gas automatically, but they can be wrong. Manual estimation:
// In your tests or scripts uint gasEstimate = contract.riskyOperation.estimateGas(100); uint safeLimit = gasEstimate * 120 / 100; // Add 20% buffer
Out of Gas Failures
When you hit the gas limit before completion:
contract OutOfGasDemo { uint[] public hugeArray; function fillArray() public { // If gas limit is too low, this fails mid-execution for(uint i = 0; i < 10000; i++) { hugeArray.push(i); // Eventually runs out of gas } // State reverts, but gas spent is NOT refunded } }
Critical point: Failed transactions still cost you gas. Setting limits too low wastes money on failed attempts.
Gas Limit Best Practices
1. Always add a buffer: Never set gas limit exactly equal to estimated gas. Add 10-20% extra.
2. Avoid unbounded loops: Never iterate over arrays without knowing their size.
contract BadPattern { address[] public users; // DANGEROUS: Gas cost unknown function processAll() public { for(uint i = 0; i < users.length; i++) { // Process each user } } } contract GoodPattern { address[] public users; // SAFE: Process in batches function processBatch(uint start, uint end) public { require(end <= users.length, "Out of bounds"); require(end - start <= 100, "Batch too large"); for(uint i = start; i < end; i++) { // Process user } } }
3. Use gas checks for complex operations:
contract GasAware { function complexOperation() public { require(gasleft() > 100000, "Not enough gas provided"); // Perform operation if(gasleft() < 50000) { revert("Running low on gas"); } } }
Variable Packing for Gas Optimization
This is one of the most powerful optimization techniques. Understanding how the EVM stores data is key to writing efficient contracts.
How EVM Storage Works
The EVM stores data in 32-byte (256-bit) slots. Each slot can hold:
- One uint256 or address or bytes32
- Multiple smaller types packed together
Reading or writing a storage slot costs the same whether you use 1 byte or all 32 bytes. This is where packing saves gas.
Storage Slot Layout
contract StorageLayout { // BAD PACKING: Uses 5 storage slots uint8 a; // Slot 0 (uses 1 byte, wastes 31 bytes) uint256 b; // Slot 1 (uses full 32 bytes) uint8 c; // Slot 2 (uses 1 byte, wastes 31 bytes) uint256 d; // Slot 3 (uses full 32 bytes) uint8 e; // Slot 4 (uses 1 byte, wastes 31 bytes) // Cost: 5 SSTORE operations = 5 × 20000 = 100000 gas }
Every SSTORE (storage write) for a new slot costs 20000 gas. This bad packing wastes 80000 gas unnecessarily.
Optimized Variable Packing
contract OptimizedStorage { // GOOD PACKING: Uses 3 storage slots uint8 a; // Slot 0 (byte 0) uint8 c; // Slot 0 (byte 1) uint8 e; // Slot 0 (byte 2) uint256 b; // Slot 1 (full 32 bytes) uint256 d; // Slot 2 (full 32 bytes) // Cost: 3 SSTORE operations = 3 × 20000 = 60000 gas // Savings: 40000 gas (40% reduction) }
The EVM packs variables declared sequentially if they fit in the same slot. Here, a, c, and e all fit in one 32-byte slot.
Practical Packing Examples
Example 1: Token Balance Tracker
// BEFORE: Bad packing (4 slots = 80000 gas) contract TokenBad { address owner; // Slot 0: 20 bytes (address) + 12 bytes wasted uint256 totalSupply; // Slot 1: 32 bytes bool paused; // Slot 2: 1 byte + 31 bytes wasted uint256 lastUpdate; // Slot 3: 32 bytes } // AFTER: Good packing (3 slots = 60000 gas) contract TokenGood { address owner; // Slot 0: 20 bytes bool paused; // Slot 0: 1 byte (packed with owner) uint256 totalSupply; // Slot 1: 32 bytes uint256 lastUpdate; // Slot 2: 32 bytes } // Savings: 20000 gas per deployment
Example 2: User Profile
// BEFORE: Bad packing (7 slots = 140000 gas) contract UserProfileBad { uint256 userId; // Slot 0 uint8 age; // Slot 1 bool isActive; // Slot 2 uint16 reputation; // Slot 3 address wallet; // Slot 4 uint32 joinDate; // Slot 5 bool isPremium; // Slot 6 } // AFTER: Good packing (3 slots = 60000 gas) contract UserProfileGood { uint256 userId; // Slot 0: 32 bytes address wallet; // Slot 1: 20 bytes uint32 joinDate; // Slot 1: 4 bytes (packed) uint16 reputation; // Slot 1: 2 bytes (packed) uint8 age; // Slot 1: 1 byte (packed) bool isActive; // Slot 1: 1 byte (packed) bool isPremium; // Slot 1: 1 byte (packed) // Total slot 1: 20+4+2+1+1+1 = 29 bytes (fits in 32) } // Savings: 80000 gas per user (57% reduction)
Understanding Variable Sizes
Here are common types and their sizes:
bool 1 byte
uint8 1 byte
uint16 2 bytes
uint32 4 bytes
uint64 8 bytes
uint128 16 bytes
uint256 32 bytes (full slot)
address 20 bytes
bytes1 1 byte
bytes32 32 bytes (full slot)
Packing Rules
- Sequential declaration matters: Variables are packed in declaration order.
contract PackingOrder { // These pack together uint128 a; // Slot 0, first 16 bytes uint128 b; // Slot 0, last 16 bytes // This needs new slot uint256 c; // Slot 1, full 32 bytes }
- Structs follow same rules:
struct BadStruct { uint8 a; // Slot 0 uint256 b; // Slot 1 uint8 c; // Slot 2 } struct GoodStruct { uint8 a; // Slot 0 uint8 c; // Slot 0 (packed) uint256 b; // Slot 1 }
- Mappings and arrays always start new slot:
contract MappingsPacking { uint128 a; // Slot 0, first 16 bytes uint128 b; // Slot 0, last 16 bytes mapping(address => uint) c; // Slot 1 (starts new slot) uint128 d; // Slot 2 (cannot pack with mapping) }
Real World Optimization Example
// NFT Contract - BEFORE (8 slots per token) contract NFTBefore { struct Token { uint256 tokenId; // Slot 0 address owner; // Slot 1 address approved; // Slot 2 uint256 price; // Slot 3 bool isListed; // Slot 4 uint8 rarity; // Slot 5 uint32 mintedAt; // Slot 6 uint16 generation; // Slot 7 } // Cost per token: 8 × 20000 = 160000 gas } // NFT Contract - AFTER (4 slots per token) contract NFTAfter { struct Token { uint256 tokenId; // Slot 0: 32 bytes uint256 price; // Slot 1: 32 bytes address owner; // Slot 2: 20 bytes address approved; // Slot 3: 20 bytes uint32 mintedAt; // Slot 2: 4 bytes (packed with owner) uint16 generation; // Slot 2: 2 bytes (packed) uint8 rarity; // Slot 2: 1 byte (packed) bool isListed; // Slot 2: 1 byte (packed) // Slot 2 total: 20+4+2+1+1 = 28 bytes } // Cost per token: 4 × 20000 = 80000 gas // Savings: 80000 gas per token (50% reduction) }
For a collection of 10000 NFTs, this saves 800 million gas in total.
Trade-offs of Variable Packing
Packing isn't always beneficial. When you read or write a packed variable, the EVM must:
- Read entire slot
- Extract your variable using bitwise operations
- Modify it
- Write entire slot back
This adds computation cost. However, this is usually cheaper than extra storage operations.
contract PackingTradeoff { // Packed: cheaper storage, slightly more computation uint128 a; uint128 b; function updateBoth(uint128 newA, uint128 newB) public { a = newA; // Read slot, modify, write slot b = newB; // Read same slot again, modify, write // Two writes to same slot in one transaction // First write: 20000 gas // Second write: 5000 gas (warm slot) // Total: 25000 gas } } contract UnpackedTradeoff { uint256 a; uint256 b; function updateBoth(uint256 newA, uint256 newB) public { a = newA; // Write slot 0 b = newB; // Write slot 1 // First write: 20000 gas // Second write: 20000 gas (different slot) // Total: 40000 gas } }
Even with the extra computation, packing saves 15000 gas here.
Gas Refunds
Ethereum gives partial refunds for freeing up storage. Setting a storage variable to zero refunds 15000 gas.
contract GasRefund { mapping(address => uint) public balances; function withdraw() public { balances[msg.sender] = 0; // Get 15000 gas refund } }
However, refunds are capped at 20% of total gas used, so you can't get more back than you spent.
Additional Optimization Techniques
Use Memory Instead of Storage
Storage is permanent and expensive. Memory is temporary and cheap.
contract OptimizedStorage { uint[] public numbers; // Expensive: Multiple storage writes function addNumbersBad(uint[] calldata newNumbers) public { for(uint i = 0; i < newNumbers.length; i++) { numbers.push(newNumbers[i]); // Each push costs 20000+ gas } } // Better: Cache length in memory function addNumbersGood(uint[] calldata newNumbers) public { uint len = newNumbers.length; // Store in memory for(uint i = 0; i < len; i++) { numbers.push(newNumbers[i]); } } }
Use Events for Data That Doesn't Need Reading
If you just need to log information without reading it back in the contract, use events. They're much cheaper than storage.
contract EventExample { event Transfer(address from, address to, uint amount); // Cheap: Event emission costs ~375 gas per topic function transfer(address to, uint amount) public { emit Transfer(msg.sender, to, amount); } }
Checking Gas Usage
You can check remaining gas during execution:
contract GasCheck { function checkGas() public view returns(uint) { return gasleft(); // Returns remaining gas } function conditionalExecution() public { if(gasleft() > 100000) { // Perform expensive operation } else { // Skip or revert } } }
Real World Complete Example
Here's a practical token contract showing gas considerations:
contract OptimizedToken { // Optimized storage layout string public name; // Slot 0-1 (dynamic) string public symbol; // Slot 2-3 (dynamic) uint256 public totalSupply; // Slot 4 address public owner; // Slot 5: 20 bytes bool public paused; // Slot 5: 1 byte (packed) uint8 public decimals; // Slot 5: 1 byte (packed) mapping(address => uint256) private balances; event Transfer(address indexed from, address indexed to, uint256 amount); function transfer(address to, uint256 amount) public { require(!paused, "Paused"); require(balances[msg.sender] >= amount, "Insufficient balance"); // Cache in memory to avoid multiple storage reads uint256 senderBalance = balances[msg.sender]; // Two storage updates: ~10000 gas total for warm slots balances[msg.sender] = senderBalance - amount; balances[to] += amount; // Event emission: ~1500 gas emit Transfer(msg.sender, to, amount); } }
Understanding gas limits and variable packing are essential skills for Solidity developers. Gas limits protect you from runaway execution costs while variable packing can cut your storage costs in half. Always pack variables of similar sizes together, avoid unbounded loops, and set reasonable gas limits with buffers. These optimizations directly translate to lower costs for your users and determine whether your contract succeeds or becomes too expensive to use.