# Yul Assembly — When and Why You Should Use It
Inline assembly gives you access to EVM opcodes that Solidity does not expose. It is powerful, gas-efficient, and dangerous.
Here is when to use it and when to avoid it.
Yul Basics
Yul is an intermediate language compiled to EVM bytecode. You write it inside assembly { ... } blocks.
function getCodeSize(address addr) public view returns (uint256 size) {
assembly {
size := extcodesize(addr)
}
}
This is not possible in pure Solidity. You need assembly.
When to Use Assembly
1. Gas-Critical Paths
If gas savings matter more than readability, assembly wins.
// Solidity: ~500 gas
function sumArray(uint256[] calldata arr) public pure returns (uint256 total) {
for (uint i = 0; i < arr.length; i++) {
total += arr[i];
}
}
// Assembly: ~300 gas
function sumArrayAsm(uint256[] calldata arr) public pure returns (uint256 total) {
assembly {
let len := arr.length
let dataPtr := arr.offset
for { let i := 0 } lt(i, len) { i := add(i, 1) } {
total := add(total, calldataload(add(dataPtr, mul(i, 32))))
}
}
}
The assembly version avoids bounds checks and memory allocation.
2. Opcodes Solidity Does Not Expose
Some opcodes have no Solidity equivalent:
returndatasize— get size of return data
returndatacopy— copy return data to memory
extcodehash— get contract code hash
function getReturnDataSize() internal pure returns (uint256 size) {
assembly {
size := returndatasize()
}
}
3. Precompile Calls
Calling precompiles (like ecrecover, sha256, or bn256 pairing) requires assembly for optimal gas usage.
function ecrecoverRaw(bytes32 hash, uint8 v, bytes32 r, bytes32 s)
public view returns (address signer) {
assembly {
let ptr := mload(0x40)
mstore(ptr, hash)
mstore(add(ptr, 32), v)
mstore(add(ptr, 64), r)
mstore(add(ptr, 96), s)
if iszero(staticcall(gas(), 1, ptr, 128, ptr, 32)) {
revert(0, 0)
}
signer := mload(ptr)
}
}
This saves gas compared to ecrecover() wrapper.
When NOT to Use Assembly
1. Normal Business Logic
If Solidity can express it clearly, do not use assembly.
// BAD: premature optimization
function add(uint256 a, uint256 b) public pure returns (uint256 c) {
assembly {
c := add(a, b)
}
}
// GOOD: readable Solidity
function add(uint256 a, uint256 b) public pure returns (uint256) {
return a + b;
}
2. When Readability Matters More Than Gas
Most contracts are not gas-bottlenecked. Clarity beats a 100 gas savings.
Common Patterns
Efficient Hashing
function hashPacked(address a, uint256 b) public pure returns (bytes32 hash) {
assembly {
let ptr := mload(0x40)
mstore(ptr, a)
mstore(add(ptr, 32), b)
hash := keccak256(ptr, 64)
}
}
Returndata Forwarding
function forwardCall(address target, bytes calldata data) external payable {
assembly {
let result := call(gas(), target, callvalue(), add(data.offset, 32), mload(data.offset), 0, 0)
let size := returndatasize()
returndatacopy(0, 0, size)
switch result
case 0 { revert(0, size) }
default { return(0, size) }
}
}
This is how proxies forward calls without decoding return data.
Packed Storage
struct PackedData {
uint128 a;
uint128 b;
}
function readPacked(PackedData storage data) internal view returns (uint128 a, uint128 b) {
assembly {
let packed := sload(data.slot)
a := and(packed, 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF)
b := shr(128, packed)
}
}
This avoids double SLOAD operations.
Common Pitfalls
1. Memory Safety
Solidity assumes memory beyond 0x40 is free. If you write to arbitrary memory, you can corrupt other data.
Always use mload(0x40) to get the free memory pointer.
2. Stack Too Deep
EVM has a 16-item stack limit. Assembly does not help — you still hit stack-too-deep errors.
// BAD: too many variables
function complexCalc(uint a, uint b, uint c, uint d, uint e, uint f, uint g)
public pure returns (uint) {
assembly {
let x := add(a, b)
let y := add(c, d)
let z := add(e, f)
let w := add(g, x)
// Stack too deep
}
}
Solution: use memory or storage.
3. No Type Safety
Yul has no type checking. Mistakes are silent.
assembly {
let x := 0x1234
mstore(x, 100) // Overwrites memory at 0x1234, probably wrong
}
Always validate offsets and pointers.
Low-Level Delegatecall
Here is a full delegatecall implementation:
function delegatecallWithRevert(address target, bytes calldata data)
external payable returns (bytes memory) {
assembly {
let ptr := mload(0x40)
calldatacopy(ptr, data.offset, data.length)
let result := delegatecall(gas(), target, ptr, data.length, 0, 0)
let size := returndatasize()
returndatacopy(ptr, 0, size)
switch result
case 0 { revert(ptr, size) }
default { return(ptr, size) }
}
}
This is how minimal proxies work.
Summary
Use assembly when:
- Gas savings justify the complexity
- You need opcodes Solidity does not expose
- You are optimizing a hot path
Do not use assembly when:
- Readability matters more than gas
- You are writing normal business logic
- You do not fully understand EVM memory layout
Assembly is a scalpel, not a hammer. Use it carefully.