Skip to main content
BlockchainMar 28, 2026

Deep EVM #5: Introduction to Yul — Solidity's Secret Assembly Language

OS
Open Soft Team

Engineering Team

What Is Yul and Why Does It Exist

Yul is an intermediate language designed by the Solidity team that compiles to EVM bytecode (and, in theory, to eWASM). It sits between Solidity and raw opcodes: you get direct access to every EVM opcode through a readable, structured syntax with variables, functions, if/switch/for constructs, and scoped blocks.

Yul exists because Solidity’s compiler generates conservative bytecode. Overflow checks, ABI encoding, memory management — all useful for application contracts but unacceptable overhead for gas-critical code like MEV bots, DEX routers, or on-chain math libraries.

When you write assembly { ... } inside Solidity, you are writing Yul.

Yul Syntax Fundamentals

Yul’s syntax is minimal. There are no types — everything is a 256-bit word. There are no arrays, no structs, no inheritance. You have:

Variables

let x := 42
let y := add(x, 1)    // y = 43
let z := mul(x, y)    // z = 42 * 43 = 1806

Variables are scoped to the enclosing block { }. They live on the stack. The Yul compiler manages stack layout for you (unlike Huff, where you manage the stack manually).

Functions

function safeAdd(a, b) -> result {
    result := add(a, b)
    if lt(result, a) {
        revert(0, 0)  // overflow
    }
}

let sum := safeAdd(100, 200)

Functions can have multiple return values:

function divmod(a, b) -> quotient, remainder {
    quotient := div(a, b)
    remainder := mod(a, b)
}

let q, r := divmod(17, 5)  // q = 3, r = 2

Control Flow

If (no else in Yul!):

if iszero(calldatasize()) {
    revert(0, 0)
}

Switch (the else equivalent):

switch selector
case 0xa9059cbb {  // transfer(address,uint256)
    // handle transfer
}
case 0x70a08231 {  // balanceOf(address)
    // handle balanceOf
}
default {
    revert(0, 0)
}

For loops:

for { let i := 0 } lt(i, 10) { i := add(i, 1) } {
    // loop body
}

Inline Assembly in Solidity

The most common way to use Yul is inside Solidity’s assembly { } blocks. Solidity variables are accessible within the block:

function getBalance(address account) external view returns (uint256 bal) {
    bytes32 slot;
    assembly {
        // balances mapping is at storage slot 0
        // balances[account] = keccak256(account . slot_0)
        mstore(0x00, account)
        mstore(0x20, 0)  // slot number
        slot := keccak256(0x00, 0x40)
        bal := sload(slot)
    }
}

This bypasses Solidity’s mapping accessor, saving gas from ABI encoding and bounds checks.

Reading and Writing Storage Directly

contract DirectStorage {
    // slot 0: owner (address, 20 bytes)
    // slot 1: totalSupply (uint256)
    // slot 2: balances mapping base

    function getOwner() external view returns (address o) {
        assembly {
            o := sload(0)  // Read slot 0 directly
        }
    }

    function unsafeSetOwner(address newOwner) external {
        assembly {
            // No access control! For demonstration only.
            sstore(0, newOwner)  // Write slot 0 directly
        }
    }
}

Emitting Events in Yul

Events in Yul are emitted using LOG opcodes (LOG0 through LOG4, where the number is the count of indexed topics):

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

function emitTransfer(address from, address to, uint256 value) internal {
    assembly {
        // Transfer(address,address,uint256) topic:
        let sig := 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef

        // Store non-indexed data in memory
        mstore(0x00, value)

        // LOG3: 3 topics (event sig + 2 indexed params) + data
        log3(
            0x00,    // memory offset of data
            0x20,    // data length (32 bytes)
            sig,     // topic 0: event signature
            from,    // topic 1: indexed from
            to       // topic 2: indexed to
        )
    }
}

This saves gas compared to Solidity’s emit Transfer(from, to, value) because we skip ABI encoding overhead.

When to Use Yul (and When Not To)

Use Yul When:

  1. Gas is critical — MEV bots, DEX aggregator routers, high-frequency on-chain operations
  2. You need bitwise operations — Packing/unpacking data, custom encoding
  3. Memory layout matters — Building calldata for external calls, custom ABI encoding
  4. Solidity generates suboptimal code — Certain patterns (like copying between memory regions) compile poorly
  5. You need transient storage — TLOAD/TSTORE before Solidity adds native support
  6. Proxy contracts — The minimal proxy (EIP-1167) is 45 bytes of hand-optimized Yul

Do Not Use Yul When:

  1. Readability matters more than gas — Most application contracts
  2. You are not confident in your EVM knowledge — Yul has no safety nets
  3. The gas savings are negligible — If saving 200 gas on a 500,000 gas function, it is not worth the audit risk
  4. You need complex data structures — Yul has no arrays, structs, or mappings at the language level

Practical Example: Efficient Multicall

Here is a real-world example — a gas-efficient multicall function that executes multiple calls in a single transaction:

function multicall(bytes[] calldata calls) external returns (bytes[] memory results) {
    results = new bytes[](calls.length);
    for (uint256 i = 0; i < calls.length;) {
        (bool success, bytes memory result) = address(this).delegatecall(calls[i]);
        require(success);
        results[i] = result;
        unchecked { ++i; }
    }
}

// Yul-optimized version:
function multicallOptimized(bytes[] calldata calls) external {
    assembly {
        let len := calls.length
        let dataOffset := calls.offset

        for { let i := 0 } lt(i, len) { i := add(i, 1) } {
            // Get the offset and length of calls[i]
            let callOffset := add(dataOffset, calldataload(add(dataOffset, mul(i, 0x20))))
            let callLen := calldataload(callOffset)
            let callData := add(callOffset, 0x20)

            // Copy calldata to memory
            calldatacopy(0x00, callData, callLen)

            // Execute delegatecall
            let success := delegatecall(gas(), address(), 0x00, callLen, 0x00, 0x00)
            if iszero(success) {
                returndatacopy(0x00, 0x00, returndatasize())
                revert(0x00, returndatasize())
            }
        }
    }
}

Practical Example: Reading Uniswap V3 Slot0

For MEV bots, reading pool state quickly is essential:

function getSlot0(address pool) external view returns (
    uint160 sqrtPriceX96,
    int24 tick,
    uint16 observationIndex,
    uint16 observationCardinality,
    uint16 observationCardinalityNext,
    uint8 feeProtocol,
    bool unlocked
) {
    assembly {
        // Prepare staticcall to pool.slot0()
        mstore(0x00, 0x3850c7bd00000000000000000000000000000000000000000000000000000000)

        // Execute staticcall
        let success := staticcall(gas(), pool, 0x00, 0x04, 0x00, 0xe0)
        if iszero(success) { revert(0, 0) }

        // Read return data directly from memory
        sqrtPriceX96 := mload(0x00)
        tick := mload(0x20)
        observationIndex := mload(0x40)
        observationCardinality := mload(0x60)
        observationCardinalityNext := mload(0x80)
        feeProtocol := mload(0xa0)
        unlocked := mload(0xc0)
    }
}

Yul vs Huff: Where Yul Falls Short

Yul is a significant step toward the metal, but it is not the final step. Compared to Huff:

FeatureYulHuff
Stack managementAutomatic (compiler)Manual
Variable namesYesNo (stack positions)
FunctionsYesMacros
Control flowif/switch/forJUMPI/JUMPDEST
Code size controlLimitedFull
Gas overheadSmall (variable management)Zero
Learning curveModerateSteep

For most gas-optimization work, Yul provides 80-90% of the savings with significantly better readability. Huff is reserved for the most extreme cases: ERC-20 implementations, MEV bot cores, and mathematical libraries where every single gas unit matters.

Common Pitfalls

1. Forgetting Memory Is Not Zeroed

Yul does not zero-initialize memory. If you read from a memory offset before writing to it, you get whatever was there before:

// BUG: memory at 0x100 may contain garbage
let value := mload(0x100)  // Could be anything!

2. Misunderstanding Return Data

After a CALL, return data is only valid until the next CALL. If you need the data, copy it to memory immediately:

let success := call(gas(), target, 0, 0, 0, 0, 0)
// returndata is available NOW
let size := returndatasize()
returndatacopy(0x00, 0x00, size)
// After the NEXT call, this returndata is gone

3. Stack Too Deep in Large Functions

Yul manages the stack for you, but the EVM stack is only 1024 elements deep with DUP/SWAP limited to 16. Large functions with many variables will fail to compile.

The fix: break large functions into smaller ones, or use memory to store intermediate values.

4. Not Handling Dirty Upper Bits

When you read an address from calldata in Yul, the upper 96 bits may be dirty (nonzero). Always mask:

let addr := calldataload(4)  // 32 bytes, but address is only 20 bytes
addr := and(addr, 0xffffffffffffffffffffffffffffffffffffffff)  // Mask to 20 bytes

Conclusion

Yul is the bridge between Solidity’s safety and the EVM’s raw power. It gives you opcode-level control with variable names, functions, and structured control flow. For MEV developers, mastering Yul is the difference between a profitable bot and one that loses to competitors on gas efficiency.

In the next article, we dive deep into Yul’s memory management: the free memory pointer, manual ABI encoding, and building external call data from scratch.