Gas Optimization & Auditing Tips

How Does a Compiler Actually Work?
Solidity is the dominant high-level programming language for writing smart contracts on Ethereum andcompatible EVM blockchains. However, the Ethereum Virtual Machine (EVM) cannot execute Solidity codedirectly. It requires low-level bytecode consisting of opcodes and data.
The Solidity compiler (solc) bridges this gap by translating human-readable Solidity into EVM-executable bytecode. Understanding how and why the compiler works is essential for writing secure, gas-efficient, and maintainablesmart contracts. It helps developers avoid common pitfalls, leverage optimizations effectively, debug issues, andensure contracts can be verified on explorers like Etherscan.
The primary implementation is solc, a C++ compiler maintained by the Solidity team (with solc-js as a JavaScript port via Emscripten). It takes Solidity source code (.sol files) as input and produces multiple outputs, including:
- EVM bytecode (creation and runtime)
- Application Binary Interface (ABI)
- Contract metadata
- Gas estimates
- Abstract Syntax Tree (AST)
- Assembly representations
- And more (via the Standard JSON interface)
In modern versions (Solidity 0.8.x series, with documentation referencing 0.8.36-develop as of mid-2026), the compiler supports two main compilation pipelines:
- Legacy pipeline (default): Direct translation from analyzed Solidity to bytecode.
- IR-based pipeline (viaIR: true): Solidity → Yul intermediate representation → optimization → bytecode. This path generally enables superior optimizations.
Fun fact, inside the solidity compiler, there are two Solidity languages. 3 if you count Core. And 4 if you count Fe! Thx Daniel for spotting this funny fact.
What Does It Actually Do?
First of all, we must note that compilers essentially take text, parse and process it, then turn it into binary for your computer to read. This keeps you from having to manually write binary for your computer, and furthermore, allows you to write complex programs easier.
In other words, the compiler converts high-level source code to low-level code. Then, the target machine executes low-level code. The compilation process consists of several phases:
- 1. Lexical analysis
- 2. Syntax analysis
- 3. Semantic analysis
- 4. Intermediate code generation
- 5. Optimization
- 6. Machine Code generation
To proceed to the next topic, we must first understand how this occurs in Solidity and, as a result, how we can use it to audit and write code safely. It’s critical to understand the specifics of the process we’ll go over below:
- Lexing and Parsing
The source code is broken into tokens (lexer) and then structured into an Abstract Syntax Tree (AST) (parser). This captures the syntactic structure of contracts, functions, variables, inheritance, etc. - Semantic Analysis
The compiler performs type checking, resolves names, handles inheritance (using C3 linearization), checks for errors (e.g., visibility, mutability), and analyzes control flow. It also processes pragmas (e.g., pragma solidity ^0.8.0;) and library linking. - Intermediate Representation (IR) Generation (especially in viaIR mode)
The analyzed code is translated into Yul, a low-level but human-readable intermediate language. Yul uses constructs like functions, if/switch/for loops, and variables while abstracting away raw EVM stack manipulation (DUP, SWAP, JUMP).
Yul serves as a clean target for optimizations and can also be written directly (via inline assembly or standalone Yul contracts). - Optimization
This is one of the most important stages. The optimizer runs at multiple levels:
- Yul optimizer (powerful in the IR pipeline): Applies passes such as common subexpression elimination (CSE), function inlining, dead code elimination, loop-invariant code motion, constant folding, and more.
- Opcode/peephole level: Simplifies instruction sequences.
- Configurable via --optimize (or optimizer.enabled: true) and --optimize-runs (default often 200).
Low runs values prioritize smaller deployment bytecode (cheaper to deploy). High values prioritize runtime gas efficiency.
- Code Generation
The optimized representation (Yul or direct) is converted into EVM bytecode. This includes generating function selectors (via keccak256 of signatures), handling storage/memory/calldata layouts, events, and constructors. The compiler also appends CBOR-encoded metadata (containing compiler version, settings, and a hash) to the end of the bytecode (unless disabled). - Output Generation
The compiler produces all requested artifacts based on the output selection.
What Does the Compiler Actually Do to Solidity Code?
We can roughly break down the entire process into the following three phases:
a) Splits code into tokens
b) Analyzes syntax
c) Constructs an AST (abstract syntax tree)
What Does the Compiler Do With AST?
Here we can also divide the entire process into three phases, which are listed below:
a) Analyzes semantics (at this point compiler errors are exposed/derived!)
b) Optimizes AST (that’s what runs value in the project’s config is specified for - check out this example!)
c) Generates bytecode!
Here is a simple example of some of the compiler’s phases:

The design is driven by fundamental constraints of the EVM:
- EVM is a stack machine with a hard limit of 1024 stack items and expensive operations (especially storage writes).
- High-level abstractions in Solidity (mappings use keccak256 hashing for storage slots, inheritance packs variables according to C3 linearization, structs/arrays have specific packing rules) must be translated into raw storage slots (0, 1, 2, ...), memory, and calldata.
- Gas is money: Every opcode has a cost. The compiler and optimizer exist to minimize both deployment size and execution gas without changing semantics.
- Security and determinism: The compiler enforces rules (e.g., overflow checks by default since 0.8.0) and produces verifiable output.
- Yul as IR: It provides a sweet spot - more optimizable than raw Solidity but more readable and portable than pure EVM assembly. This enables whole-program optimizations that would be difficult at the opcode level.
Why It Is Important to Understand the Compiler
Many developers treat the compiler as a black box. This leads to suboptimal or insecure contracts. Here’s why deep understanding pays off:
1. Gas Optimization (The Biggest Practical Benefit)
The optimizer does a lot automatically, but it cannot fix fundamentally expensive patterns. Understanding compilation helps you:
- Write code the optimizer loves (e.g., simple expressions, reusable functions).
- Manually optimize where needed (storage packing, using calldata vs memory, avoiding unnecessary state changes, custom errors instead of require strings).
- Choose the right optimizer-runs and consider enabling viaIR for better results in many cases.
- Inspect Yul or assembly output to see what the compiler actually generates.
2. Security and Correctness
- Storage layout collisions are a major risk in upgradeable contracts and inheritance. The compiler’s deterministic layout rules (starting at slot 0, packing rules, keccak for mappings) must be respected.
- Compiler bugs, while rare, have existed historically. Using recent, audited versions and pinning exact versions (pragma solidity 0.8.XX;) reduces risk.
- Understanding how features compile (e.g., modifiers, inheritance) helps spot subtle issues.
3. Debugging and Maintenance
Source maps and assembly output allow stepping through code at the EVM level. When things go wrong on-chain, inspecting bytecode or Yul is often necessary.
4. Contract Verification and Reproducibility
Explorers verify source by recompiling it with the exact compiler version and settings. Mismatched settings produce different bytecode, breaking verification.
5. Advanced Development and Tooling
- Inline assembly/Yul gives fine-grained control when Solidity abstractions are insufficient.
- Frameworks (Hardhat, Foundry, etc.) expose compiler settings—knowing them lets you tune builds effectively.
- Future-proofing: As EVM evolves (new versions, EOF), the compiler adapts. Understanding pipelines helps adopt improvements.
6. Best Practices
Always:
- Pin exact compiler versions.
- Enable the optimizer (and consider viaIR).
- Review warnings as errors.
- Use the latest stable 0.8.x version for safety features.
- Inspect storageLayout for complex contracts.
Gas Optimization & Auditing Tips
- Modifiers: each inclusion of `_` in a modifier inserts the function body into bytecode. If a function has several modifiers, it may significantly increase the size of the contract and the cost of deployment. If you need to save money, you can combine modifiers or put the checks into a separate function;
- In older versions of solidity, reading the storage length of the array (array.length) in the loop condition means reading from storage at each iteration;
- Sometimes when auditing code, you may notice unnecessary copying (calldata->storage; calldata->memory; memory <-> storage) when assigning or passing arguments to a function. For example, you should always mark reference-type arguments of external functions as calldata, not memory; sometimes you may even use storage references in internal calls;
- You can change the order of storage variables or fields in a structure somewhere to use storage packing, and it will be useful. However, sometimes reading and writing with storage packing is not always cheaper than without it. You can save a lot of gas when writing a storage array of structures with packed fields;
- In Solidity, some data types have a higher gas cost than others. And that is what is often required of a smart contract developer and that’s why you should understand the gas utilization of the available data types;
- Custom errors is cheaper to use than revert(“error text”). There is more information in this article: soliditylang.org/blog/2021/04/21/custom-errors.
The integration of your project will be substantially more secure if you implement the below recommendations:
- Functions: Internal calls preserve the context (msg.sender, msg.value, etc). For example, an internal call to `transfer(…)` inside a token transfers tokens from the address of the caller, not from the balance of the token contract itself;
- Functions: external > public
- Variables: Visibility of private and internal does not hide data. Variable values are readable from off-chain;
- Variables: Public visibility for variables creates getters and the code of getters takes up space in the contract. When optimizing gas, you can remove Public visibility for some variables (unused or already read in some other functions) and thus reduce gas consumption during contract execution;
- Constants: Magic constants are dangerous, so make full-fledged constants with a normal name. Immutable is also ok;
- Constants: Function signatures should be checked using 4bytes;
- Ether: payable - can be redundant (the function can receive ether but does not process it);
- Ether: fallback vs receive; receive is sufficient for receiving money. It often includes a check that the broadcast comes from one of the addresses (e.g. WETH);
- Ether: It is impossible to limit the consumption of ether (selfdestruct, mining), so you cannot rely on the exact value of the balance. This is also true for tokens;
- Ether: Three Ether sending options: send, call, transfer:
a) Specifics of use address.transfer() - throws on failure, forwards 2,300 gas stipend (not adjustable), safe against reentrancy, should be used in most cases as it’s the safest way to send ether;
b) Specifics of use address.send() - returns false on failure, returns false on failure, should be used in rare cases when you want to handle failure in the contract;
c) Specifics of use address.call.value().gas()() - returns false on failure, forwards all available gas (adjustable), not safe against reentrancy, should be used when you need to control how much gas to forward when sending ether or to call a function of another contract;
- It’s bad form to work directly with gas: When working with the tx.gasprice variable, it is necessary to remember that its value is set by the user at the moment of transaction execution;
- It’s bad form to work directly with gas: release of a specific amount of gas through .gas(X) / {gas: X}
- .transfer VS .send VS .call — call is a good standard, but it brings its own set of problems (reentrancy etc);
- It is easy to make collisions, for example with arrays: abi.encodePacked([1,2],[3]) == abi.encodePacked([1],[2,3])) so check them out carefully. Technically, similar can be done with abi.encode, e.g. via structures.
- Use SafeMath and analogs for Solidity <0.8 (and do not use for more recent ones), keep in mind that a.add(b).mul(c) == (a+b)*c ;
- Very carefully check all unchecked sections for Solidity >=0.8 ;
- It is better to do all calculations in the uint256 type, because, for example, in the case of `uint256(a) = uint16(b) * uint32(c) / uint16(d)` the right part may overflow, because the intermediate value may not fit into uint32 (for each operation the maximum of operand types is taken, i.e. the result of multiplication of uint16 and uint32 will be uint32);
- Type conversion - always check that the number is converted normally. Recommendation: use libraries like SafeCast ;
- It’s almost always multiplication first, then division;
- When calculating fractions, don’t forget that you can accidentally get zero. e.g. `balanceOf(user) / totalSupply() == 0`. You need to multiply by a suitable multiplier (often 1e18).
- It is better to put the additional multiplier in a constant like `uint constant private HUNDRED_PERCENT=1e18;` ;
- There are times when you need to calculate the sum of fractions (i.e., the common denominator of all fractions). It is correct to add first, then divide;
- Instead of `a/b > c/d` it is often better to use `a*d > c*b`.
Short Types in Solidity: Rare Tricks Uncovered
In Solidity, some data types have a higher gas cost than others. And that is what is often required of a smart contract developer and that’s why you should understand the gas utilization of the available data types; - in order to choose the most efficient one according to your needs. For the purposes of this article, we refer to uint8-uint248 and int8-int248 types as “short types”.
- Solidity compiler tightly packs short types (if possible) to reduce storage footprint of storage variables, struct fields, array elements;
- Short types require more opcodes and gas in all other cases (calldata, memory, stack).
- Overflows happen more often;
- Solidity 0.8 and SafeMath do not check for overflows during type casts;
- Short types behave differently for abi.encodePacked and other abi.encode* calls.
- Solidity ABI encoder puts every value into a separate slot, i.e. the size of calldata or returndata remains the same.
- The compiler often inserts additional opcodes - it may inflate the size of the contract.
I recommend:
- Using short types only to reduce storage footprint;
- Local variables, return values, function and event parameters should be uint256 or int256
- Converting uint256 values to shorter types right before you write them to storage. Utilize libs like a SafeCast; - they have a pretty good syntax;
- When you read short type value from storage, convert it to uint256 immediately
- Utilizing storage location when possible: function foo(MyStruct storage ms, uint32[] storage array) internal {…}
- Read individual fields or elements if you don’t need the whole structure or array: uint256 value = uint256(ms.field) + uint256(array[i]);
Conclusion
The Solidity compiler is far more than a simple translator - it is a sophisticated tool that handles the immense complexity of mapping high-level smart contract logic onto a constrained, gas-metered virtual machine. It performs parsing, semantic analysis, powerful optimizations (especially via Yul in the IR pipeline), and generates critical artifacts like ABI and metadata.
Developers who understand its inner workings - how code becomes bytecode, how storage is laid out, how the optimizer functions, and what outputs it produces - write better contracts. They save on gas, reduce security risks, debug faster, and build more reliable decentralized applications.
In the evolving world of blockchain development (with L2s, account abstraction, and potential EVM upgrades), compiler literacy is no longer optional - it is a core skill for professional Solidity engineers.
Master the compiler, and you master the bridge between your ideas and the blockchain.


