Gas and Gas Price in Solidity

Gas is the unit that measures computational effort on the Ethereum network. Every operation your smart contract performs costs gas, and gas costs real money paid in ETH. If your contract is inefficient, it becomes expensive for users to interact with. During periods of network congestion, the same transaction can cost 10x more than during quiet periods.

This post explains how gas works, how to set gas limits correctly, and how to cut storage costs using variable packing.

What is Gas and How is Cost Calculated

Every time a user calls a function or deploys a contract, the EVM executes your code across thousands of nodes. Those nodes need compensation for their work. Gas is how that compensation is measured and charged.

The formula is simple:

Transaction Cost = Gas Used x Gas Price

Gas price is measured in gwei. One gwei equals 0.000000001 ETH. During peak network usage, gas prices can swing from 20 gwei to 200 gwei or higher. The same operation that costs $2 at 20 gwei can cost $20 at 200 gwei.

Not all operations cost the same amount of gas. Here is a rough breakdown of common EVM operations:

OperationGas Cost
Basic arithmetic3 gas
Reading from storage (cold)2100 gas
Writing to storage (new value)20000 gas
Writing to storage (update)5000 gas
Event emission~375 gas per topic

Storage operations are by far the most expensive. Designing your contract to minimize storage reads and writes is the single most impactful optimization you can make.

How Gas Limits Work

The gas limit is the maximum gas you are willing to spend on a transaction. It exists to protect you from runaway execution. Without a limit, a bug causing an infinite loop would drain your wallet completely.

There are two kinds of gas limits you need to understand:

Block gas limit is set by the network's validators. It caps the total gas allowed across all transactions in one block. On Ethereum mainnet this is around 30 million gas. It determines how many transactions can fit in a block.

Transaction gas limit is the value you set when sending a transaction. This is what you control.

Here is exactly what happens when you submit a transaction:

  1. You set a gas limit (say, 100000 gas)
  2. You set a gas price (say, 50 gwei)
  3. Your wallet reserves 100000 x 50 = 5000000 gwei (0.005 ETH)
  4. The transaction executes and uses actual gas (say, 65000)
  5. You pay for 65000 x 50 = 3250000 gwei (0.00325 ETH)
  6. The remaining 35000 gas is refunded

The important thing to understand: if your transaction runs out of gas before completing, the EVM reverts all state changes but you do not get the gas back. You pay for the work done up to the point of failure.

contract GasLimitDemo {
    uint[] public numbers;

    // Gas cost grows with each iteration
    // Calling addNumbers(1000) needs far more gas than addNumbers(10)
    function addNumbers(uint count) public {
        for (uint i = 0; i < count; i++) {
            numbers.push(i);
        }
    }
}

If you call addNumbers(1000) with a gas limit of 100000 but the function needs 500000 gas, the transaction fails at the limit, all changes revert, and you pay for the gas consumed up to 100000.

Failed transactions still cost gas. Setting the gas limit too low does not save money. It wastes money on failed attempts while leaving state unchanged.

Setting the Right Gas Limit

Most wallets estimate gas automatically, but their estimates can be wrong for complex or loop-heavy functions. You can add a safety buffer in your scripts:

// Estimate gas then add 20% buffer
const gasEstimate = await contract.riskyOperation.estimateGas(100);
const safeLimit = Math.floor(gasEstimate * 1.2);

A 10-20% buffer is standard practice. For functions with variable loop counts, test with realistic inputs before deploying.

Avoiding Unbounded Loops

The most common cause of out-of-gas failures is iterating over an array whose size can grow without bound.

contract BadPattern {
    address[] public users;

    // DANGEROUS: gas cost is unknown and grows over time
    function processAll() public {
        for (uint i = 0; i < users.length; i++) {
            // process each user
        }
    }
}
contract GoodPattern {
    address[] public users;

    // SAFE: caller controls the batch size
    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
        }
    }
}

The batch pattern lets you process large arrays across multiple transactions without hitting the block gas limit.

Checking Remaining Gas at Runtime

For complex operations, you can read remaining gas mid-execution and bail out early if you are running low:

contract GasAware {
    function complexOperation() public {
        require(gasleft() > 100000, "Not enough gas provided");

        // perform expensive work

        if (gasleft() < 50000) {
            revert("Insufficient gas to complete safely");
        }
    }
}

Use gasleft() sparingly. It is useful for guard rails in loops or multi-step operations, but it adds complexity.

Variable Packing for Storage Optimization

Storage is the most expensive part of a Solidity contract. Variable packing is a technique that reduces the number of storage slots your contract uses, which directly reduces gas costs.

How EVM Storage Works

The EVM stores state variables in 32-byte (256-bit) slots. Reading or writing a slot costs the same amount of gas whether you use 1 byte or all 32 bytes. If you declare your variables carelessly, you can waste entire slots on variables that only need a few bytes.

Consider this unpacked layout:

contract UnpackedStorage {
    uint8 a;    // Slot 0: uses 1 byte, 31 bytes wasted
    uint256 b;  // Slot 1: full 32 bytes
    uint8 c;    // Slot 2: uses 1 byte, 31 bytes wasted
    uint256 d;  // Slot 3: full 32 bytes
    uint8 e;    // Slot 4: uses 1 byte, 31 bytes wasted
}
// 5 storage slots = 5 x 20000 = 100000 gas to initialize

Now the same data, packed:

contract PackedStorage {
    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
}
// 3 storage slots = 3 x 20000 = 60000 gas to initialize
// Saves 40000 gas (40% reduction)

The EVM packs variables into the same slot automatically, but only if they are declared sequentially and fit together. Declaration order matters.

Common Variable Sizes

TypeSize
bool1 byte
uint81 byte
uint162 bytes
uint324 bytes
uint648 bytes
uint12816 bytes
uint25632 bytes
address20 bytes
bytes3232 bytes

Practical Examples

Token contract storage layout:

// BAD: 4 storage slots = 80000 gas
contract TokenBad {
    address owner;       // Slot 0: 20 bytes + 12 bytes wasted
    uint256 totalSupply; // Slot 1: 32 bytes
    bool paused;         // Slot 2: 1 byte + 31 bytes wasted
    uint256 lastUpdate;  // Slot 3: 32 bytes
}
// GOOD: 3 storage 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
}
// Saves 20000 gas per deployment

User profile with many small fields:

// BAD: 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
}

// GOOD: 3 slots = 60000 gas
contract UserProfileGood {
    uint256 userId;    // Slot 0: 32 bytes

    // Slot 1: 20 + 4 + 2 + 1 + 1 + 1 = 29 bytes (fits in 32)
    address wallet;    // Slot 1: 20 bytes
    uint32 joinDate;   // Slot 1: 4 bytes
    uint16 reputation; // Slot 1: 2 bytes
    uint8 age;         // Slot 1: 1 byte
    bool isActive;     // Slot 1: 1 byte
    bool isPremium;    // Slot 1: 1 byte
}
// Saves 80000 gas per user (57% reduction)

Packing Rules for Structs and Mappings

Structs follow the same packing rules as contract-level variables:

struct BadStruct {
    uint8 a;    // Slot 0
    uint256 b;  // Slot 1 (breaks packing)
    uint8 c;    // Slot 2
}

struct GoodStruct {
    uint8 a;    // Slot 0
    uint8 c;    // Slot 0 (packed)
    uint256 b;  // Slot 1
}

Mappings and dynamic arrays always start a new storage slot and cannot be packed with adjacent variables:

contract MappingLayout {
    uint128 a;                   // Slot 0, first 16 bytes
    uint128 b;                   // Slot 0, last 16 bytes
    mapping(address => uint) c;  // Slot 1 (always starts new slot)
    uint128 d;                   // Slot 2 (cannot pack with mapping)
}

Real-World Example: NFT Struct

Here is how packing applies to a token struct in an NFT contract:

// BEFORE: 8 slots per token = 160000 gas
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
}
// AFTER: 4 slots per token = 80000 gas
struct Token {
    uint256 tokenId;   // Slot 0: 32 bytes
    uint256 price;     // Slot 1: 32 bytes

    // Slot 2: 20 + 4 + 2 + 1 + 1 = 28 bytes
    address owner;     // Slot 2: 20 bytes
    uint32 mintedAt;   // Slot 2: 4 bytes
    uint16 generation; // Slot 2: 2 bytes
    uint8 rarity;      // Slot 2: 1 byte
    bool isListed;     // Slot 2: 1 byte

    address approved;  // Slot 3: 20 bytes
}
// Saves 80000 gas per token (50% reduction)
// For 10000 NFTs: 800 million gas saved in total

The Tradeoff: Packing Adds Computation

Packing is not free. When you read or write a packed variable, the EVM has to read the full 32-byte slot, extract your variable with bitwise operations, modify it, and write the slot back. This adds a small computation cost.

In practice, packing is almost always worth it because storage operations are so much more expensive than arithmetic. Here is a concrete comparison:

contract PackedUpdate {
    uint128 a;
    uint128 b;

    function updateBoth(uint128 newA, uint128 newB) public {
        a = newA; // First write to slot 0: 20000 gas
        b = newB; // Second write to same slot (warm): 5000 gas
        // Total: 25000 gas
    }
}
contract UnpackedUpdate {
    uint256 a;
    uint256 b;

    function updateBoth(uint256 newA, uint256 newB) public {
        a = newA; // Write to slot 0: 20000 gas
        b = newB; // Write to slot 1: 20000 gas
        // Total: 40000 gas
    }
}

The packed version saves 15000 gas even accounting for the extra computation. The exception is when two packed variables are always read or written independently and rarely together. In that case, packing may add overhead without benefit. Measure before assuming.

Other Optimization Techniques

Cache Storage Reads in Memory

Every time you read a storage variable, it costs 2100 gas (cold) or 100 gas (warm). If you read the same variable multiple times in a function, cache it in a local memory variable first.

function transfer(address to, uint256 amount) public {
    // Cache the balance once instead of reading storage twice
    uint256 senderBalance = balances[msg.sender];
    require(senderBalance >= amount, "Insufficient balance");

    balances[msg.sender] = senderBalance - amount;
    balances[to] += amount;
}

Use Events for Data You Do Not Need On-Chain

If you need to record something for off-chain use but never read it inside the contract, use events. They cost roughly 375 gas per indexed topic, far cheaper than a storage write.

event Transfer(address indexed from, address indexed to, uint256 amount);

function transfer(address to, uint256 amount) public {
    // state changes here
    emit Transfer(msg.sender, to, amount); // ~1500 gas total
}

Storage Refunds

Setting a storage slot to zero gives you a gas refund of 15000 gas. This makes cleanup functions cheaper to run.

function close() public {
    delete balances[msg.sender]; // Refunds 15000 gas
}

Refunds are capped at 20% of total gas used for the transaction, so you cannot refund more than you spent.

Putting It Together: An Optimized Token Contract

This example combines the techniques covered above into a working token contract:

contract OptimizedToken {
    // Dynamic strings take their own slots
    string public name;
    string public symbol;

    uint256 public totalSupply;

    // Slot: 20 + 1 + 1 = 22 bytes, packed together
    address public owner;
    bool public paused;
    uint8 public decimals;

    mapping(address => uint256) private balances;

    event Transfer(address indexed from, address indexed to, uint256 amount);

    function transfer(address to, uint256 amount) public {
        require(!paused, "Contract is paused");

        // Read storage once, use local variable for checks
        uint256 senderBalance = balances[msg.sender];
        require(senderBalance >= amount, "Insufficient balance");

        balances[msg.sender] = senderBalance - amount;
        balances[to] += amount;

        emit Transfer(msg.sender, to, amount);
    }
}

The contract packs owner, paused, and decimals into one slot, caches the sender balance to avoid a redundant storage read, and uses an event for the transfer log instead of extra storage.

Summary

Gas optimization is not premature optimization. In Ethereum, every byte you waste costs your users real money. The two techniques covered here give you the most leverage:

Gas limits protect you from runaway transactions. Always add a 10-20% buffer on top of estimated gas. Never write unbounded loops. Use batching when you need to process large arrays.

Variable packing cuts storage costs by filling each 32-byte slot fully. Declare small types together, group related fields in structs, and remember that mappings always start a new slot. For a large NFT collection or a frequently-updated contract, the savings compound quickly.

Start with packing your storage layout before deployment. It costs nothing at that stage and the savings are permanent.