This is a 3-part series that assumes you know Solidity and want to understand YUL. We will start from absolute basics and build up to writing real contracts.
YUL is a low-level language that compiles to EVM bytecode. When you write Solidity, the compiler turns it into YUL, then into bytecode. Writing YUL directly gives you precise control over what the EVM executes.
Code Repository: All code examples from this guide are available in the YUL Examples repository. Each part has its own directory with working examples you can compile and test.
YUL is a human-readable, low-level intermediate language that compiles to EVM bytecode. It is what Solidity compiles to before becoming the bytecode that runs on Ethereum.
Important distinction: YUL is not raw EVM assembly. It is a structured intermediate representation (IR) that compiles to EVM opcodes. YUL provides higher-level constructs (functions, variables, control flow) that are then compiled down to the stack-based EVM opcodes.
You can write YUL in two ways:
This guide covers both approaches. We start with inline assembly to bridge from Solidity, then show standalone YUL contracts.
Before diving into syntax, understand these core principles:
This mental model will help you understand why YUL code looks the way it does.
YUL syntax is minimal:
The EVM is a stack-based machine. All operations work with values on a stack. You push values, operate on them, and pop them off. The stack has a maximum depth of 1024 items.
5 // Push 5 → stack: [5]
3 // Push 3 → stack: [5, 3]
add // Pop 3 and 5, add them, push 8 → stack: [8]
Visualization:
Initial: []
After 5: [5]
After 3: [5, 3]
After add: [8]
add // Addition
sub // Subtraction (second - top)
mul // Multiplication
div // Division (second / top)
mod // Modulo (second % top)
Important: For sub, div, and mod, the operation is second_value op top_value.
Example:
// Stack: [10, 3]
sub // 10 - 3 = 7 (result: [7])
div // 10 / 3 = 3 (result: [3])
mod // 10 % 3 = 1 (result: [1])
eq // Equal (returns 1 if equal, 0 if not)
lt // Less than (second < top)
gt // Greater than (second > top)
iszero // Is zero (returns 1 if top is 0)
and // Bitwise AND (commonly used as logical when values are 0 or 1; YUL does not have a boolean type)
or // Bitwise OR (commonly used as logical when values are 0 or 1; YUL does not have a boolean type)
xor // XOR
not // Bitwise NOT (flips all 256 bits)
Bit shifting moves bits left or right in a number:
shl(bits, value): Shift left (multiply by 2^bits)
shr(bits, value): Shift right (divide by 2^bits)
Why shifting matters:
YUL has only one data type: 256-bit unsigned integers (u256). No strings, arrays, structs, or booleans. Use 0 for false, 1 for true.
Inline assembly still uses the stack underneath. When you write add(a, b) in an assembly block, Solidity automatically places a and b on the stack, then add pops both values, adds them, and pushes the result. This is the same stack operations as the raw 5, 3, add example.
The difference is syntax: inline assembly lets you use function-like syntax while the stack operations happen automatically. In standalone YUL, you are responsible for value lifetimes and ordering, even though YUL provides expression syntax (like add(x, y)) that abstracts individual dup and swap instructions.
The easiest way to start with YUL is using inline assembly inside Solidity contracts. This lets you write YUL code within familiar Solidity syntax.
Let us start with a simple Solidity function and convert it to inline assembly:
Solidity version:
function add(uint256 a, uint256 b) public pure returns (uint256) {
return a + b;
}
Inline assembly version:
function add(uint256 a, uint256 b) public pure returns (uint256 result) {
assembly {
result := add(a, b)
}
}
What changed:
Note: You can also define variables inside the assembly block using let:
assembly {
let sum := add(a, b) // Define variable inside assembly
// Use sum here
}
Why this is useful:
Memory is a temporary byte array used to store intermediate values and return data. It is not persisted between calls.
Memory is organized in 32-byte words. Address 0 = first 32 bytes, address 32 = next 32 bytes, and so on.
mstore(position, value): Stores a 256-bit value at a memory position
mstore(0, 42) // Store 42 at position 0-31
mstore(32, 100) // Store 100 at position 32-63
mload(position): Loads a 256-bit value from memory
mstore(0, 42)
let value := mload(0) // value is now 42
Why we need memory:
Important note for inline assembly in Solidity:
For learning clarity, we use memory slot 0 in examples. Production Solidity inline assembly should allocate memory using the free memory pointer.
What is scratch space?
What is the free memory pointer?
How to use it in production:
assembly {
// Get the current free memory pointer
let freeMemPtr := mload(0x40)
// Use this address for your data
mstore(freeMemPtr, yourData)
// Update the free memory pointer (move it forward by 32 bytes)
mstore(0x40, add(freeMemPtr, 32))
}
For standalone YUL contracts, you have full control and can use any memory position.
Important: mstore and mload always operate on 32 bytes, regardless of the starting position.
If you use unaligned addresses (not multiples of 32), you can overwrite adjacent data:
mstore(7, 42) // Writes 32 bytes starting at byte 7 → writes to bytes 7-38
mstore(18, 100) // Writes 32 bytes starting at byte 18 → writes to bytes 18-49
// These writes overlap! Bytes 18-38 are overwritten by both operations.
Problems with unaligned writes:
Best practice: Always use aligned addresses (0, 32, 64, 96, …) for mstore and mload. Use mstore8 if you need byte-level writes.
To return data from a function, you must:
return(offset, size): Returns size bytes starting from memory position offset
let result := 42
mstore(0, result) // Store result in memory
return(0, 32) // Return 32 bytes from position 0
⚠️ Important for inline assembly: In inline assembly, return() immediately exits the entire Solidity function and returns raw bytes, bypassing Solidity's normal return handling. Use it only when you intend to fully control the return data.
Complete example:
function getValue() public pure returns (uint256) {
assembly {
let value := 42
mstore(0, value)
return(0, 32) // Exits function immediately, returns raw bytes
}
}
Calldata is the input data sent with a transaction. It contains the function selector (first 4 bytes) and function parameters (ABI-encoded).
For a function like transfer(address to, uint256 amount):
calldataload(position): Loads 32 bytes from calldata
let selector := calldataload(0) // Loads bytes 0-31
Important: Production YUL code should check calldatasize() before reading parameters to avoid out-of-bounds reads. The examples in this guide assume calldata is long enough for demonstration purposes.
Extracting the function selector:
calldataload(0) loads 32 bytes, but the function selector is only the first 4 bytes. We need to extract just those 4 bytes.
The problem:
The solution: Shift right by 224 bits
let selector := shr(224, calldataload(0)) // Shift right by 224 bits
Why 224 bits?
Visual example:
Before shift (32 bytes):
[selector (4 bytes)][padding (28 bytes)]
0xa9059cbb00000000000000000000000000000000000000000000000000000000
After shr(224, ...):
[zeros][selector (4 bytes)]
0x00000000000000000000000000000000000000000000000000000000a9059cbb
^^^^^^^^
Just the selector
The selector is now in the rightmost 4 bytes, with zeros on the left.
Reading function parameters:
// Read 'to' (skip 4 bytes for selector)
let to := calldataload(4)
// Mask to get just the address (20 bytes)
to := and(to, 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF)
// Read 'amount' (skip 4 + 32 = 36 bytes)
let amount := calldataload(36)
Why mask addresses:
How masking works:
The and operation performs bitwise AND: it keeps bits where both values are 1, and clears bits where either is 0.
The mask 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF is 20 bytes of all 1s (rightmost 20 bytes). When you and a value with this mask:
Why masking is necessary:
Masking is defensive programming: it ensures we only use the rightmost 20 bytes even if calldata is malformed or contains unexpected values.
In normal cases, addresses are already left-padded with zeros:
Input: 0x0000000000000000000000001234567890123456789012345678901234567890
^^^^^^^^^^^^^^^^^^^^^^^^ already zeros
kept ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
But what if calldata is malformed or a malicious caller passes non-zero values in the leftmost 12 bytes?
Malicious input: 0xFFFFFFFFFFFFFFFFFFFFFFFF1234567890123456789012345678901234567890
^^^^^^^^^^^^^^^^^^^^^^^^^^ garbage (could cause issues)
What could go wrong without masking:
If you use an unmasked address value directly, the garbage bytes could:
⚠️ Always mask addresses from calldata.
Note: This function is not meant to be realistic. It is a mechanical demonstration of concepts covered in Part 1.
Here is a complete function demonstrating all concepts from Part 1:
function processAddressAndAmount(address addr, uint256 amount) public pure returns (uint256)
assembly {
// 1. Extract function selector (demonstrates bit shifting)
// calldataload(0) loads 32 bytes, but selector is only 4 bytes
// Shift right by 224 bits (28 bytes) to move selector to rightmost position
let selector := shr(224, calldataload(0))
// 2. Read address from calldata (position 4, after selector)
let addr := calldataload(4)
// 3. Mask address to ensure only 20 bytes (demonstrates defensive programming)
// Addresses are 20 bytes, but calldata pads them to 32 bytes
// Masking clears any potential garbage in the leftmost 12 bytes
addr := and(addr, 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF)
// 4. Read uint256 from calldata (position 36, after selector + address)
let amount := calldataload(36)
// 5. Store values in memory (demonstrates memory operations)
mstore(0, addr) // Store address at position 0
mstore(32, amount) // Store amount at position 32
// 6. Perform a calculation (e.g., add selector to amount for demonstration)
// In a real contract, you'd do something meaningful with addr and amount
let result := add(selector, amount)
// 7. Return the result
mstore(0, result) // Store result at memory position 0
return(0, 32) // Return 32 bytes from memory position 0
}
}
Note: The function has parameters (`address addr, uint256 amount`) that Solidity would normally handle automatically. The parameters are intentionally unused in the function body. We read from calldata manually to demonstrate how calldata parsing works. In practice, you would use the parameters directly, but reading from calldata shows the low-level mechanics.
What happens step by step:
Standalone YUL files (.yul) compile directly to bytecode. They require more setup but give you full control.
A standalone YUL contract has two parts:
object "ContractName" {
code {
// Deployment code - runs once when contract is created
datacopy(0, dataoffset("runtime"), datasize("runtime"))
return(0, datasize("runtime"))
}
object "runtime" {
code {
// Runtime code - this is the actual contract
// Your functions go here
}
}
}
What each part does:
datacopy(dest, offset, size): Copies data to memory
dataoffset("name"): Returns the offset of a named object's data
datasize("name"): Returns the size of a named object's data
Why this structure:
Important: This standalone contract does not implement ABI dispatch. Any calldata sent is interpreted as raw arguments (function selector + parameters). Any call to this contract, regardless of selector, will execute the same code. In a real contract, you would check the function selector and route to different handlers (we’ll cover this in Part 3).
Here is a complete standalone YUL contract demonstrating all Part 1 concepts:
object "ProcessAddress" {
code {
// Deployment code - runs once when contract is created
// 1. Copy runtime code to memory using datacopy
// - Destination: memory position 0
// - Source: dataoffset("runtime") - where the runtime object's data starts
// - Size: datasize("runtime") - how many bytes the runtime code is
datacopy(0, dataoffset("runtime"), datasize("runtime"))
// 2. Return the runtime code (this is what gets stored on-chain)
// - Offset: 0 (where we copied the code in memory)
// - Size: datasize("runtime") (how many bytes to return)
return(0, datasize("runtime"))
}
object "runtime" {
code {
// Runtime code - this runs on every function call
// 1. Extract function selector (demonstrates bit shifting)
// calldataload(0) loads 32 bytes, but selector is only 4 bytes
// Shift right by 224 bits to move selector to rightmost position
let selector := shr(224, calldataload(0))
// 2. Read address from calldata (position 4, after selector)
let addr := calldataload(4)
// 3. Mask address to ensure only 20 bytes (demonstrates defensive programming)
// Addresses are 20 bytes, but calldata pads them to 32 bytes
// Masking clears any potential garbage in the leftmost 12 bytes
addr := and(addr, 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF)
// 4. Read uint256 from calldata (position 36, after selector + address)
let amount := calldataload(36)
// 5. Store values in memory (demonstrates memory operations)
mstore(0, addr) // Store address at position 0
mstore(32, amount) // Store amount at position 32
// 6. Perform a calculation (e.g., add selector to amount)
// In a real contract, you'd do something meaningful with addr and amount
let result := add(selector, amount)
// 7. Return the result
mstore(0, result) // Store result at memory position 0
return(0, 32) // Return 32 bytes from memory position 0
}
}
}
What each part does:
Deployment (code block):
2. return(0, datasize("runtime")):
Runtime (runtime object's code block):
Avoid these common pitfalls when learning YUL:
1. Forgetting to mask addresses
2. Using unaligned mstore addresses
3. Assuming Solidity safety checks exist
4. Using return() in inline assembly without understanding
5. Using memory slot 0 in production Solidity code
In the next part, we will dive deep into storage, the persistent database where contract state lives. We’ll see why incorrect storage access is far more dangerous than incorrect memory usage.
Questions? Drop a comment below and I’ll do my best to help!
Want to stay updated? Follow to get notified when Part 2 is published.
YUL: Solidity’s Low-Level Language (Without the Tears), Part 1: Stack, Memory, and Calldata was originally published in Coinmonks on Medium, where people are continuing the conversation by highlighting and responding to this story.


