Events and Logs
The previous page, Storage Slot Layout, left us with a hard number: writing a fresh 32-byte storage slot costs on the order of 20,000 gas, and that slot then lives in the state trie that every full node must store and re-hash forever. Storage is the contract’s live memory — the thing the world computer agrees on. But most of what a contract wants to announce to the outside world is not state the chain needs to reason about later. When a token moves, the chain must remember the new balances (storage). It does not need to remember, in queryable on-chain form, that “at 14:03 Alice sent Bob 5 tokens” — a wallet or a block explorer does, but they live off the chain.
This page is about that second channel: events, the mechanism a contract uses to emit logs — a cheap, append-only record committed alongside each transaction that is built for off-chain consumers to read, and that on-chain code deliberately cannot read back. It is the clearest example in this whole part of the book’s cost question: what does it cost to run one? Logs exist because the answer to “should the chain remember this in expensive, contract-readable storage?” is very often no — and the EVM gives you a far cheaper place to put it.
The problem: storage is expensive and outward-facing apps need history
Section titled “The problem: storage is expensive and outward-facing apps need history”A contract’s storage answers one question well: what is the current state? Balance of an account, owner of an NFT, the current price. It answers a different question terribly: what happened, and when? To reconstruct history from storage you would have to store every past value in its own slot — paying 20,000 gas each and bloating the state trie without bound. And even then an off-chain app could only read it by making an RPC eth_call per slot.
Meanwhile the things off-chain apps need most are exactly history and notifications:
- A wallet wants to show you “your last 20 transfers” — a stream of past events, not a single current balance.
- An indexer (The Graph, a block explorer, an exchange’s deposit watcher) wants to be told the instant a relevant thing happens, so it can update its own database.
- A dapp frontend wants to react to on-chain activity without polling every storage slot every block.
Storage is the wrong tool for all three: too expensive to write, and impossible to subscribe to. Ethereum’s answer is a separate, purpose-built structure — the log — with its own opcodes, its own place in the block, and its own gas price.
What a log actually is: topics + data
Section titled “What a log actually is: topics + data”At the EVM level, emitting a log is a family of five opcodes: LOG0, LOG1, LOG2, LOG3, LOG4. The digit is the number of topics the log carries. Every log entry has exactly this shape:
one LOG entry ┌──────────────────────────────────────────────────────────────┐ │ address : 0xC0ntract… (which contract emitted it — filled │ │ in by the EVM, not by you) │ │ │ │ topics : [ topic0, topic1, topic2, topic3 ] ← 0 to 4 │ │ each topic is exactly 32 bytes │ │ │ │ data : 0x…arbitrary bytes… ← any length, ABI-encoded │ └──────────────────────────────────────────────────────────────┘Two parts, and the split between them is the whole design:
- Topics are up to four 32-byte words. They are indexed: clients can filter on them cheaply and quickly (that is what the Bloom filter, below, is for). Think of them as the columns you would put a database index on.
- Data is an arbitrary-length blob of bytes. It is not indexed — you cannot filter by it — but it is far cheaper per byte and can hold as much as you like. Think of it as the un-indexed payload columns.
By deep-rooted convention, topic0 is the event’s signature hash: keccak256("Transfer(address,address,uint256)"). That single 32-byte value is how a client says “show me all Transfer events” without knowing anything else. (Anonymous events — rare — omit topic0 and free up a fourth indexable slot; you will almost never write one.)
Events: the Solidity face of logs
Section titled “Events: the Solidity face of logs”You almost never write LOG2 by hand. Solidity gives you event declarations and the emit statement, and the compiler lowers them to the right LOGn opcode. The canonical example is the ERC-20 Transfer event:
// SPDX-License-Identifier: MITpragma solidity ^0.8.20;
contract Token { mapping(address => uint256) public balanceOf;
// 'indexed' picks which params become searchable topics. event Transfer(address indexed from, address indexed to, uint256 value);
function transfer(address to, uint256 value) external { require(balanceOf[msg.sender] >= value, "insufficient");
// STORAGE writes — the chain must remember the new balances. balanceOf[msg.sender] -= value; balanceOf[to] += value;
// LOG — cheap, append-only announcement for off-chain consumers. emit Transfer(msg.sender, to, value); }}Look closely at the division of labor inside transfer. The two balanceOf updates are storage — the live state the world computer agrees on, so that the next transfer sees the right balance. The emit Transfer(...) is a log — it changes no state the EVM will ever read again; it exists purely so that Alice’s wallet and Etherscan can see the transfer happened. Same event, two entirely different channels, chosen deliberately for cost.
indexed vs non-indexed: where each parameter lands
Section titled “indexed vs non-indexed: where each parameter lands”The indexed keyword is the single most important choice in an event declaration. It decides whether a parameter becomes a topic (searchable) or goes into the data blob (not searchable, cheaper).
For event Transfer(address indexed from, address indexed to, uint256 value):
emit Transfer(0xAli.., 0xBob.., 5)
topic0 = keccak256("Transfer(address,address,uint256)") ← event signature topic1 = 0x000…0xAlice (from — indexed) ← searchable topic2 = 0x000…0xBob (to — indexed) ← searchable data = 0x000…0005 (value — NOT indexed) ← in the blobNow a wallet can ask the node: “give me every log where topic0 = Transfer-hash AND topic2 = 0xBob” — i.e. every transfer to Bob — and get an efficient, filtered answer. It could not efficiently ask “every transfer of exactly 5 tokens,” because value is in the un-indexed data.
The rules and trade-offs:
- At most 4 topics, and topic0 is spent on the signature hash (unless
anonymous), so at most 3 event parameters can beindexed. Choose the three someone will filter by — usually addresses and IDs. - Indexed dynamic types are hashed, not stored. If you mark a
stringorbytesasindexed, the topic holdskeccak256(value), not the value itself. You can match on it (you know the string you are looking for) but you cannot recover the original string from the topic. Recoverable values must go indata. - Data is cheaper per byte and unbounded. Put amounts, timestamps, prices, and long payloads there — anything you need to read but never to filter on.
The mental model: topics are the WHERE clause, data is the SELECT payload. Index what you query by; blob what you only display.
Where logs live: the receipts trie, not the state trie
Section titled “Where logs live: the receipts trie, not the state trie”This is the crux of why logs are cheap and why contracts cannot read them. A log is not part of the world state. It is part of the transaction receipt.
Recall from Storage Slot Layout and the state & tries part that the stateRoot commits to every account’s balance, nonce, code, and storage — the mutable world the EVM reads and writes. A block header carries three Merkle roots, and logs live under a different one:
block header (partial) ┌────────────────────────────────────────────────┐ │ stateRoot → the world state (accounts, │ ← contracts read/write this │ storage). Contracts SEE this.│ │ transactionsRoot → the txs in this block │ │ receiptsRoot → one receipt per tx, and each │ ← logs live HERE │ receipt carries its LOGS │ │ logsBloom → a 2048-bit filter over ALL │ ← the fast index (below) │ logs in the block │ └────────────────────────────────────────────────┘Each transaction produces a receipt: its status (success/revert), cumulative gas used, and the ordered list of logs it emitted. Those receipts are hashed into the receiptsRoot. So logs are committed to the chain — you can prove a specific log was emitted with a Merkle proof against receiptsRoot, which is exactly how a light client or an L2 verifies “this event happened.” They are permanent and tamper-evident. They are simply committed separately from state.
Our companion crate ethmini models the receipt as the record of what a transaction did, though it keeps things minimal:
/// The record of what a transaction did, returned by `WorldState::apply`.pub struct Receipt { pub status: ExecStatus, pub gas_used: u64, pub return_value: Option<u64>, // the word a RETURN produced, if any pub created: Option<Address>, // for a deploy, the new contract address // Real Ethereum receipts also carry: a Vec<Log> and a per-tx Bloom filter.}The teaching receipt has no logs field, because the toy VM has no LOG opcode — but the shape is right: a receipt is the out-of-state record of a transaction, and in real Ethereum its logs are exactly what off-chain consumers subscribe to. The point that matters is structural: state is in stateRoot; logs are in receiptsRoot. Two different commitments for two different jobs.
Under the hood — why contracts cannot read logs
Section titled “Under the hood — why contracts cannot read logs”Here is the property that surprises everyone: there is no opcode to read a log. LOG0–LOG4 are write-only. No LOGLOAD, no way for any contract — not even the one that emitted the event — to query past events during execution.
This is not an oversight; it falls out of the architecture above. During transaction execution the EVM operates on the state (stateRoot) — that is what SLOAD/SSTORE touch. Logs go into the receipt (receiptsRoot), which is assembled as a side output of execution and is not available as an input to execution. Determinism demands it: every node must compute the exact same result from the exact same inputs, and the inputs are the pre-state plus the transaction. Letting code branch on historical logs would drag receipt history into the execution model and shatter that clean separation.
The consequence for design is blunt: if a contract needs to act on a value later, that value must be in storage, not in a log. Logs are a one-way door out to the off-chain world. Anything the chain must reason about lives in state; anything only humans and their software need lives in logs. Confusing the two is a real bug class — “I emitted it, why can’t my other function see it?” The answer is: because logs were never for contracts.
The Bloom filter: finding matching logs without reading every block
Section titled “The Bloom filter: finding matching logs without reading every block”Say your wallet wants every Transfer to your address across the last million blocks. Naively, a node would open every block, walk every receipt, and check every log’s topics — millions of comparisons per query. Ethereum makes the common case fast with a Bloom filter in each block header: the 2048-bit logsBloom.
A Bloom filter is a probabilistic set membership structure. It can tell you, cheaply and in constant space, “this block definitely does not contain a log matching X” or “this block might contain one — go look.” It never gives a false negative (never hides a real match) but it can give false positives (occasionally says “maybe” when the answer was “no”). For a client scanning history that trade is perfect: skip the vast majority of blocks instantly, and only fully open the handful the filter flags.
Under the hood — how the 2048-bit filter is built
Section titled “Under the hood — how the 2048-bit filter is built”For every log, the EVM takes the emitting address and each topic, hashes them with keccak256, and uses parts of that hash to set 3 bits (out of 2048) in the block’s logsBloom. Every log in the block OR-folds its bits into the same 2048-bit vector.
Building logsBloom for a block:
for each log in the block: for each item in [ log.address, topic0, topic1, topic2, topic3 ]: h = keccak256(item) set 3 bits of the 2048-bit filter, chosen from h (OR them into logsBloom)
Querying "does block B maybe contain a Transfer to Bob?": compute the 3 bits for keccak256(Transfer-signature-hash) compute the 3 bits for keccak256(0xBob) if EVERY one of those bits is set in B.logsBloom → MAYBE, open the block if ANY bit is clear → NO, skip the block entirelyBecause setting bits is a pure OR, checking is a pure AND-mask: to test whether a block might contain a log matching a set of topics, compute the bits those topics would set and check they are all present. If any required bit is 0, the block cannot contain the log — skip it without touching a single receipt. This is precisely what a node does behind eth_getLogs and the eth_subscribe("logs", …) filter: Bloom-scan headers to shortlist candidate blocks, then open only those to confirm and extract the actual logs.
The cost of the trick is false positives — as a block fills with logs, more bits get set, and the filter says “maybe” more often, forcing more real lookups. But it never misses a true match, so correctness is preserved and the common case (most blocks are irrelevant to your filter) stays cheap.
Cost: log versus storage write, quantified
Section titled “Cost: log versus storage write, quantified”Return to the book’s second question with real figures. The reason to reach for a log instead of a storage write is almost always price.
The design rule that falls out:
- Storage is the contract’s memory — for values the contract (or another contract) must read on-chain later. You pay for it in gas and in permanent state growth.
- Logs are the contract’s audit trail and notification channel — for facts only off-chain software needs. Cheap, permanent-but-outside-state, and unreadable by contracts.
Emitting an event you never needed is a minor waste; putting in storage something that only a frontend reads is an expensive mistake — you paid 10× and grew the state forever for data the chain never uses. The most common real-world pattern is exactly the Token above: write the new balances to storage (the chain needs them), and emit an event (the world wants to know).
The architect’s lens
Section titled “The architect’s lens”- Why does it exist? To give contracts a channel that is cheap and subscribable for telling the outside world what happened, without paying storage prices or bloating the state trie that every node keeps forever. Storage answers “what is true now?”; logs answer “what happened, and when?”.
- What problem does it solve? Off-chain consumers — wallets, indexers, dapp frontends — need efficient, filterable history and notifications. Reconstructing that from storage would be ruinously expensive and impossible to subscribe to; logs plus the Bloom filter make it fast and cheap. See the Bloom filter.
- What are the trade-offs? Logs are ~10× cheaper than storage and add nothing to permanent state — but they are write-only from a contract’s view and only indexable on up to 3 chosen parameters (4 topics minus the signature). You trade on-chain readability and rich queryability for cost.
- When should I avoid it? When the contract itself (now or later) must read the value — use storage. Logs are unreadable by on-chain code, so any value a function will branch on, verify, or return must live in a storage slot, never only in an event.
- What breaks if I remove it? The entire off-chain ecosystem goes dark: wallets can’t show transaction history, explorers can’t display token transfers, indexers like The Graph have nothing to index, and dapp frontends can’t react to on-chain events. The chain would still compute correct state — but nobody outside a node could efficiently observe it.
Next, we leave execution behind and ask how a contract’s code got onto its address at all: Deployment: CREATE and CREATE2 →
Check your understanding
Section titled “Check your understanding”- A log entry has two parts, “topics” and “data.” How many topics can a log carry, what is topic0 almost always used for, and how does that limit the number of
indexedevent parameters you can declare? - In the
Token.transferfunction, the new balances are written to storage and aTransferevent is emitted. Why both? What does each channel accomplish that the other cannot? - A contract emits
event Deposited(uint256 amount). Later, a different function in the same contract needs to know the total deposited. Can it read that back from the emitted logs? Explain why or why not, and where the value must actually live. - Explain the block’s
logsBloomfilter: what question does it answer, what kind of error can it make (and what kind can it never make), and how does a node use it to answer “give me everyTransferto Bob” without opening every block? - Using rough gas figures, contrast emitting a
Transferevent with recording the same information in a fresh storage slot — in both immediate gas cost and long-term cost to the network. Which block-header root commits to each?
Show answers
- A log carries 0 to 4 topics, each exactly 32 bytes. Topic0 is (by convention)
keccak256of the event signature, e.g.keccak256("Transfer(address,address,uint256)"), which is how clients filter for “all events of this type.” Because topic0 is spent on the signature (for non-anonymousevents), at most 3 event parameters can beindexed— the remaining three topic slots. Non-indexed parameters go into the arbitrary-length data blob and are not searchable. - The storage writes update the live balances the chain must agree on so the next transfer computes correctly — on-chain code will read those slots again. The event is a cheap, out-of-state announcement so off-chain consumers (wallets, explorers, indexers) learn the transfer happened; on-chain code never reads it. Storage can’t be efficiently subscribed to or filtered by history; logs can’t be read by contracts. You need both because they serve two different audiences (the chain vs. the outside world).
- No. Logs are write-only from a contract’s perspective — there is no opcode to read a past log, not even one the contract itself emitted. Logs live in the receipts trie (
receiptsRoot), which is a side output of execution and not an input to it, so execution can’t branch on it (and determinism forbids it). For a function to read the running total, that total must be kept in storage (a slot committed tostateRoot), which is whatSLOAD/SSTOREoperate on. - The
logsBloomis a 2048-bit probabilistic set built by hashing each log’s address and topics and OR-ing 3 bits per item into the block header’s filter. It answers “might this block contain a log matching these topics?” It can produce false positives (“maybe” when there was no real match) but never false negatives (it never hides a real match). To find everyTransferto Bob, a node computes the bits for theTransfersignature hash and for Bob’s address, AND-masks them against each block’slogsBloom, skips any block missing a required bit, and only opens the shortlisted blocks to confirm and extract the actual logs. - Emitting a
Transferevent costs roughly ~1,800 gas (375 base + 375 per topic × 3 + 8 per data byte × 32) and adds nothing to permanent state — it lives in thereceiptsRoot. Recording the same info in a fresh storage slot costs ~20,000 gas and sits in thestateRoot, which every full node stores and re-hashes forever. So the log is roughly an order of magnitude cheaper and has no lasting state footprint, whereas the storage write is expensive both now and permanently — which is why facts only off-chain software needs belong in logs, and only chain-relevant state belongs in storage.