devoracles.

Solidity smart contract development: a dev's gas optimization
Developer Tools & SDKs

Solidity smart contract development: a dev's gas optimization

Gas optimization in Solidity smart contract development rarely begins with a clever opcode trick.

Solidity Smart Contract Development: A Dev’s Gas Optimization

More often, it starts with a transaction that is simply doing too much: writing several storage slots, copying a large array from calldata into memory, decoding data that will never be used, or reverting with a long string after the expensive part of the call has already happened.

That is the part many teams discover late. The contract works. The tests are green. Deployment succeeds. Then a real user submits a transaction, and the gas profile reveals that the most expensive operation is not the calculation everyone was focused on. It is storage.

Let us dive into what actually moves the needle in Solidity gas optimization: storage layout, calldata and memory, compiler settings, arithmetic checks, revert data, transient storage, and measurement. The goal is not to make every function shorter. The goal is to make the contract spend gas where it creates value—and nowhere else.

Gas optimization starts with the execution model

The EVM does not think in terms of high-level Solidity variables. It executes operations over 256-bit words and charges for work such as reading storage, writing storage, copying data, expanding memory, and returning revert data.

That creates an immediate mismatch between how code looks and how it costs.

A declaration such as uint8 score appears cheaper than uint256 score, but the EVM still works with 32-byte words. A storage write may look like a single assignment, while under the hood it can involve reading an existing slot, masking bits, combining values, and writing the updated word. An external function parameter may look like an ordinary array, while Solidity may copy it into memory before your function starts processing it.

The useful question is therefore not, “Which Solidity type is smaller?” It is:

What data does this function need, where does that data live, and how many times will the EVM touch it?

That question gives us a much more reliable optimization path than collecting isolated tips from old snippets.

Storage is usually the first place to look

Persistent storage is expensive because it survives the transaction. A previously empty 32-byte storage slot costs 22,100 gas to write, according to the Ethereum execution model described in the Solidity documentation. That is not a minor detail. A function that initializes ten new slots can spend a substantial amount of its budget before performing any meaningful business logic.

Storage reads and writes also affect architecture. If a value is needed only during one transaction, storing it permanently may be unnecessary. If a value is used repeatedly across many calls, packing or caching can make sense. If a value is written once during deployment but read thousands of times afterward, deployment-focused optimization may be the wrong trade.

Start by separating your state into three groups:

  • Persistent state: data that must be available to future transactions.
  • Transaction-local data: values that can be reconstructed or passed through the current call.
  • Derived data: values that can be calculated from existing state instead of stored independently.

The third category is particularly interesting. Storing both totalDeposits and a separately updated availableBalance may save a calculation in one function, but it also creates another value that must remain synchronized. The gas cost is not only the extra write. It is the additional invariant your tests must protect.

For oracle-based contracts, this distinction becomes even more important. A feed consumer might need to persist the latest accepted price, round identifier, timestamp, and signer set. It probably does not need to persist every intermediate value used while validating the update. Keeping the on-chain state model narrow makes both the transaction cheaper and the contract easier to reason about.

Storage packing: useful, but not a universal win

Solidity can place multiple contiguous values smaller than 32 bytes into one 32-byte storage slot when the layout rules allow it. This is known as storage packing.

For example, a structure containing a uint128, another uint128, and perhaps a smaller flag can often use fewer slots than the same structure with each value separated by a full-width type. Fewer slots can mean fewer storage operations, particularly when the values are written together.

But packing is not simply a matter of changing every uint256 to uint8.

The EVM operates on 32-byte words. When a packed value is read or updated, Solidity may need to mask, shift, or convert the relevant bits. In some execution paths, those extra operations can offset the saving—or make the packed version more expensive than a full-width value.

This is where access patterns matter. Packing fields that are always read and written together is usually more attractive than packing fields that are updated independently. If changing one flag requires touching a slot that also contains a frequently updated counter, the layout may introduce extra work and make the code harder to maintain.

A practical storage review should ask:

  • Are the fields contiguous in the contract or struct layout?
  • Are their combined sizes within one 32-byte slot?
  • Are they usually accessed together?
  • Does updating one field force a read-modify-write cycle for the entire slot?
  • Would a full-width type simplify arithmetic or reduce conversions?
  • Is the state written during every user operation, or only during setup?

Storage layout is part of the contract’s data model, not a cosmetic refactor. Once a contract is deployed, changing the layout can be a compatibility problem. Treat it with the same care as an external interface.

A compact comparison

Design choiceWhere it helpsWhat can go wrong
Pack contiguous small valuesFewer storage slots and potentially fewer storage operationsBit masking and conversion can add execution cost
Use uint256 for arithmeticMatches the EVM word size and simplifies operationsMay use more storage if values could safely share a slot
Store derived valuesFaster reads in selected pathsMore writes and more state invariants
Recompute derived valuesReduces persistent stateCan increase computation or require repeated external reads
Cache data in storageAvoids repeated expensive work across callsEvery cache update costs gas and can become stale

The best layout is the one that matches the contract’s dominant workload. A vault, an oracle adapter, and a voting contract will not have the same optimal storage arrangement.

Storage optimization is not about making variables smaller. It is about reducing expensive state transitions without creating a more fragile state machine.

Calldata versus memory: stop copying data you do not need to change

Reference-type parameters—arrays, bytes, and strings—can live in memory, storage, or calldata. For external function arguments, calldata is often the right default when the function only needs to read the input.

Consider a function that receives a batch of signed price updates. If the contract validates the updates but never modifies them, copying the entire array into memory is boilerplate with a gas bill attached. Declaring the parameter as calldata allows the function to read the transaction input directly and avoids that copy.

The shape is simple:

  • Use calldata for external read-only input.
  • Use memory when the function needs a mutable temporary structure.
  • Use storage only when you intentionally want to reference persistent state.

This is especially relevant to Web3 developer tools that process batched data. Multicall helpers, token routers, oracle adapters, and bridge verification functions often accept arrays of addresses, signatures, values, or update payloads. The larger the batch, the more expensive an unnecessary copy becomes.

Ethereum calldata costs 4 gas per zero byte and 16 gas per non-zero byte. That means calldata itself is not free, and its cost depends on the bytes being transmitted. But avoiding a second copy still matters. The optimization is not “calldata is always cheap.” It is “do not duplicate input data when direct read access is enough.”

Keep the data path narrow

A common mistake in validation functions is accepting a large structure because it is convenient for the caller, then decoding or copying every field even though the contract uses only two of them.

For instance, a feed update may include:

  • a value,
  • a timestamp,
  • a round identifier,
  • a source identifier,
  • a signature,
  • and metadata used by an off-chain aggregator.

The contract should decode the fields it actually verifies. If metadata is needed only by the off-chain system, sending it through the on-chain path may add calldata cost without improving security.

The same principle applies to function return values. Returning a large array from a view function may not charge the caller in the same way as a state-changing transaction, but it still affects RPC response size, client performance, and the overall developer experience. Gas optimization is part of infrastructure design, not only a Solidity syntax exercise.

A useful implementation habit is to annotate intent directly in the signature:

function submitUpdates(Update[] calldata updates) external

The calldata keyword communicates to the next developer that these values are read-only inputs. That small decision can prevent an accidental memory copy during a later refactor.

Compiler optimization is a product decision

Solidity’s optimizer is not trying to produce one universally cheapest contract. It makes trade-offs based on how often it expects the generated code to run.

The optimizer assumes each opcode is executed approximately 200 times by default. The optimize-runs setting changes that assumption.

With --optimize-runs=1, the compiler favors cheaper deployment and smaller creation-time cost. That can make sense for a contract deployed once and called rarely, or for a factory that creates many short-lived instances.

With a higher runs value, the compiler is more willing to spend deployment gas or produce larger bytecode in exchange for cheaper repeated execution. That is more appropriate for a heavily used router, token, registry, or oracle consumer that will process a large number of calls over its lifetime.

Neither setting is automatically correct. The right value depends on:

  • how often the contract will be deployed;
  • how frequently its hot functions will run;
  • whether deployment cost is paid by a factory or individual users;
  • bytecode size constraints;
  • the target network and its fee model;
  • and which functions dominate real usage.

This is a good place to resist cargo-cult configuration. Copying an optimizer setting from another repository tells us very little unless the two contracts have similar deployment and call patterns.

Measure both creation and runtime behavior

A deployment-focused optimizer setting can reduce creation-time cost while increasing the cost of later calls. A runtime-focused setting can do the opposite. That trade-off belongs in your release process.

The Solidity compiler can emit gas estimates as part of its compilation output. Those estimates are useful for comparing builds and catching unexpected changes, but they are not a complete benchmark. Dynamic behavior, storage state, calldata contents, cold versus warm accesses, and revert paths all affect actual execution.

This is where a framework such as Foundry becomes valuable. Foundry’s gas snapshot tooling includes snapshotGasLastCall(string), which records the gas used by the last call under a named label. In a test, the pattern can be as direct as:

vm.startSnapshotGas("oracle_update"); is not the documented function we should rely on here; instead, use the supported snapshot interface available in your Foundry version, including snapshotGasLastCall("oracle_update") after the target call.

The exact test harness can evolve, but the engineering idea is stable: name the operation, execute a realistic scenario, record gas, and compare the result over time.

For a production contract, useful snapshots might include:

1. A first-time deposit that writes new storage.

2. A repeat deposit that updates an existing position.

3. A batch oracle update with a small payload.

4. The same update with a full batch.

5. A failed update with invalid signatures.

6. A withdrawal after several state transitions.

7. A deployment with the selected optimizer configuration.

The failed path deserves attention. Reverts are not free, and long revert strings increase bytecode size and revert-data payload. A contract that rejects malformed messages frequently may spend meaningful resources on error handling.

Custom errors reduce noise in the failure path

Custom errors use an ABI-encoded selector and arguments instead of embedding a long revert string. Solidity documentation describes their syntax for contracts using Solidity ^0.8.4.

The difference is straightforward:

require(amount > 0, "Deposit amount must be greater than zero");

can become:

if (amount == 0) revert ZeroAmount();

The custom error can also include structured data, such as InvalidRound(expected, received), when the caller or front end needs context.

This can reduce deployment bytecode compared with verbose revert strings and can make revert data more structured for clients. It does not mean every transaction becomes cheaper. The clearest gains are usually associated with contract size and the data returned on failure, not with a blanket reduction in all successful execution costs.

Errors also improve the contract’s interface. A front end can identify InvalidSigner or StaleUpdate without parsing human text. That is useful for Web3 developer tools, testing environments, and automated monitoring.

Keep the errors specific enough to diagnose the failure, but avoid turning every branch into a payload-heavy diagnostic report. A short selector plus the one or two values needed to understand the rejection is often enough.

Arithmetic checks: use unchecked only after proving the bound

Since Solidity 0.8.0, arithmetic operations revert on overflow and underflow by default. That default is an important safety improvement, but the checks introduce work. In a narrow loop with a proven bound, an unchecked block can remove those checks.

For example, an index increment may be safe when the loop condition guarantees that the index cannot exceed the array length. The code can express that locally:

unchecked { ++i; } // Safe because i is bounded by updates.length

The comment is not decoration. It documents the invariant that makes the optimization valid.

Do not use unchecked as a general-purpose gas-saving switch. It is appropriate only when overflow and underflow are ruled out by the surrounding logic. A counter that can be incremented by an external user, a balance calculation involving untrusted values, or a token amount derived from an exchange rate needs a different level of scrutiny.

Also remember that unchecked does not disable division-by-zero or modulo-by-zero checks. Those conditions remain guarded. The block changes arithmetic overflow and underflow behavior; it does not turn every arithmetic operation into unchecked machine code.

A safe review process looks like this:

  • Identify the exact operation that is expensive.
  • Write down the invariant that bounds the value.
  • Confirm the invariant is enforced on every path.
  • Add the smallest possible unchecked block.
  • Test the boundary values explicitly.
  • Keep a gas snapshot before and after the change.

If the benchmark cannot show a meaningful improvement, keep the checked version. Fewer safety guarantees should buy us something tangible.

Transient storage changes what “temporary” can mean

Transient storage uses the TSTORE and TLOAD opcodes. It survives across internal calls during a single transaction, but it is cleared when that transaction ends and is not committed to global contract storage.

That makes it useful for transaction-scoped state.

One example is a reentrancy guard. A traditional guard writes a persistent storage slot, even though the lock is relevant only while the current transaction is executing. Transient storage can represent that short-lived state without leaving a permanent value behind.

Other potential uses include:

  • passing a temporary flag across internal calls;
  • coordinating multi-step logic inside one transaction;
  • preventing repeated execution during a single call graph;
  • storing intermediate values that must be visible to nested contracts but not to future transactions.

The feature is powerful, but it adds another storage model to the contract. Every developer touching the code must understand that transient state disappears at the end of the transaction. It cannot replace persistent storage for balances, accepted oracle rounds, ownership, or any value that future calls must read.

It also deserves network compatibility testing. EVM-compatible networks may differ in feature support, compiler targets, and execution costs. There is no single optimization rule that transfers unchanged from Ethereum mainnet to every rollup or alternative EVM chain.

Build a measurement loop, not a bag of tricks

Gas optimization becomes much easier when it is treated as a feedback loop:

1. Define the user operation that matters.

2. Write a realistic test for that operation.

3. Capture a baseline gas measurement.

4. Change one thing.

5. Run correctness and security tests.

6. Compare the new measurement.

7. Keep the change only if the trade-off is acceptable.

This sounds obvious, but many teams skip step two. They optimize a small internal function in isolation, even though the real transaction cost is dominated by storage writes or external calls around it.

What a useful gas test contains

A good test uses realistic state. If the target function behaves differently for an empty slot and an already initialized slot, benchmark both. If the contract receives batches, test more than one batch size. If the code has an oracle freshness check, include current, stale, and boundary timestamps.

The test should also separate successful and failed paths. A revert caused before storage writes has a different cost profile from a revert after signature verification and state mutation attempts.

Foundry snapshots are particularly useful for regression testing because they make gas changes visible in code review. A pull request that modifies a struct layout, compiler configuration, or function parameter type should show whether the hot-path snapshots moved.

Gas reports and compiler estimates can help us identify suspicious functions, but neither one is a security guarantee. A cheaper function can still contain a reentrancy issue, incorrect access control, an invalid signature check, or a broken invariant. Correctness remains the gate; gas is the optimization target inside the safe design space.

Compiler settings belong in version control

Do not leave optimizer configuration only in a local command or an undocumented deployment script. Pin the compiler version, optimizer settings, target framework configuration, and deployment assumptions in the repository.

That lets the team answer practical questions later:

  • Why did bytecode size change?
  • Why did deployment gas increase?
  • Was the optimizer configured for deployment or runtime usage?
  • Did a compiler upgrade alter the generated code?
  • Are the gas snapshots comparing equivalent builds?

The same discipline applies to via-ir and other code-generation choices. They may improve a particular contract, but the available research does not support a universal percentage saving across representative contracts. Measure them against your own workloads rather than treating them as magic switches.

A worked optimization path for an oracle consumer

Let us take a typical oracle update function. It receives a batch of signed observations, verifies the signers, checks that the update is newer than the stored round, and writes the accepted result.

The first version often contains familiar sources of waste:

  • memory parameters for data that is never modified;
  • a verbose revert string for every validation failure;
  • separate storage variables that could share a slot;
  • repeated reads of the same stored configuration;
  • arithmetic checks inside a bounded loop;
  • and no gas regression test for the batch path.

A practical pass would look like this.

1. Move read-only external inputs to calldata

Change external arrays and byte payloads to calldata when the function only reads them. This avoids an unnecessary copy and makes the intended data flow explicit.

2. Cache repeated storage reads

If the function reads the quorum threshold or the current accepted round multiple times, load it once into a local variable. The cache should be a read optimization, not an excuse to duplicate state.

3. Review the state layout

Place fields that are logically updated together next to one another when packing is safe. Then benchmark the result. If the new layout introduces frequent masking or conversion work, the full-width version may be the better engineering choice.

4. Replace long revert strings with custom errors

Use errors such as StaleRound, InvalidSignature, or InsufficientQuorum. Include arguments only where they help the caller understand the failure.

5. Inspect bounded loop arithmetic

If the loop index and accumulator have proven bounds, consider a narrow unchecked block. Do not wrap signature counts, token amounts, or values whose range depends on untrusted input without a clear invariant.

6. Consider transaction-local coordination

If the update flow uses a temporary lock or passes a short-lived marker through internal calls, transient storage may be a better fit than a persistent slot—provided the deployment targets support the required opcodes.

7. Snapshot the real scenarios

Measure the first update, repeat update, largest expected batch, stale update, and invalid signer path. The cheapest happy path is not enough.

For developers building oracle infrastructure around real-world data, the same methodology applies beyond financial feeds. A contract might consume tournament standings, team metadata, or match status—for example, an application tracking current PUBG Mobile World Cup 2026 tournament data. The feed payload, update frequency, and failure modes will differ from a price oracle, but the integration questions remain familiar: what must be persisted, what can stay in calldata, and which validation work is repeated unnecessarily?

The optimization trade-offs that deserve a second look

Some techniques are attractive because they are easy to explain. They are also easy to misuse.

Smaller integers

Smaller integer types can help storage packing, but they do not automatically make arithmetic cheaper. The EVM operates on 256-bit words, and conversions or masking can add work. Use smaller types when their range is meaningful and the storage layout benefits—not because uint8 sounds more efficient than uint256.

More caching

Caching a calculated value can reduce runtime computation, but it adds a write and a consistency requirement. Cache only values whose reuse justifies the storage cost and whose invalidation rules are clear.

Assembly and low-level calldata handling

Inline assembly can remove abstraction overhead in tightly controlled code. It can also bypass Solidity’s safety checks around memory, bounds, ABI decoding, and type handling. That makes it a specialist tool, not a default optimization step. First prove that the high-level version is the bottleneck. Then isolate the low-level code, document its invariants, and test malformed input aggressively.

Optimizer runs

A setting that helps a frequently called production contract may be a poor fit for a factory or a one-off deployment. Benchmark creation and runtime costs separately, and include bytecode size in the decision.

Custom errors

They are an excellent replacement for long revert strings, but they do not make the successful path automatically cheaper. Their value is clearest in bytecode and revert-data efficiency, plus more structured client handling.

Transient storage

It is excellent for transaction-scoped coordination, not for state that future calls must recover. The lifetime of the data is the deciding factor.

Where the gains usually come from

After the first serious measurement pass, the improvements tend to cluster around a few areas:

  • fewer persistent storage writes;
  • fewer unnecessary memory copies;
  • tighter calldata payloads;
  • better storage layout for frequently updated state;
  • smaller revert data and bytecode;
  • reduced repeated reads;
  • and arithmetic checks removed only where invariants make that safe.

Notice what is missing: a universal percentage saving. There is no honest number that applies to every contract, chain, compiler configuration, and workload. A gas optimization that helps an NFT mint may be irrelevant to a high-frequency oracle adapter. A change that saves deployment gas may increase the cost of every later call.

That is why the most valuable Web3 developer tools are often the least glamorous ones: compiler output, framework traces, gas snapshots, fuzz tests, invariant tests, and a repeatable deployment pipeline. They give us evidence before the optimization becomes folklore.

The final standard: cheaper, explainable, and still correct

A strong Solidity smart contract development workflow does not treat gas as a contest to minimize a single number. It treats gas as one dimension of a system that also includes safety, upgradeability, readability, client compatibility, and operational cost.

Before merging an optimization, we should be able to explain:

  • which operation became cheaper;
  • under which input and storage conditions;
  • whether deployment or runtime cost changed;
  • what invariant makes the change safe;
  • how the result was measured;
  • and whether the code is still understandable to the next developer.

That final point matters more than it sounds. Smart contracts live in repositories long after the original author has moved on. A compact but mysterious assembly routine can cost more engineering time than it saves in gas. A carefully chosen calldata parameter, a measured storage layout, or a named Foundry snapshot is often a better optimization because the benefit survives code review.

The best result is not the cleverest contract. It is a contract whose expensive operations are deliberate, whose temporary data does not become permanent state by accident, and whose gas profile is tested like any other production behavior.

Put the benchmark beside the implementation, keep the relevant tests in the repository, and invite another developer to challenge the assumptions. That is how gas optimization becomes part of the engineering process rather than a last-minute scramble before deployment.

FAQ

Why is storage the most expensive part of a smart contract?
Persistent storage is expensive because it survives the transaction, with a previously empty 32-byte slot costing 22,100 gas to write.
Should I always use the smallest possible integer type to save gas?
No, because the EVM operates on 32-byte words. Smaller types only save gas if they allow for effective storage packing; otherwise, the extra masking and shifting operations can make them more expensive.
When should I use the unchecked block in Solidity?
You should only use unchecked blocks when you have proven that overflow or underflow is impossible due to the surrounding logic and have documented this invariant.
What is the benefit of using custom errors instead of revert strings?
Custom errors reduce deployment bytecode size and provide more structured data for clients to interpret failures, though they do not necessarily make successful transactions cheaper.
How does transient storage differ from persistent storage?
Transient storage is cleared at the end of a transaction and is intended for short-lived data like reentrancy guards or temporary flags, whereas persistent storage is saved to the blockchain.