eth_getLogs: Query Event Logs by Address and Topic
eth_getLogs•Read method•Ethereum JSON-RPCeth_getLogs is how you read Ethereum's event history. You hand the node a filter holding a block range and, optionally, a contract address and topic pattern, and it returns every matching log in order. Indexers, ERC-20 accounting, DEX analytics and liquidation monitors are all eth_getLogs loops underneath, which also makes it the method that fails most often: every shared endpoint caps it somewhere.
Try eth_getLogs
curl -s https://ethereum-rpc.publicnode.com \
-X POST \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "eth_getLogs",
"params": [
{
"fromBlock": "latest",
"toBlock": "latest",
"address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
"topics": [
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
]
}
]
}'Runs the example straight from your browser against the endpoint above. The prefilled URL is a third-party public endpoint, not run by BLAZED.sh.
What eth_getLogs does
Events are not state. When a contract emits, the EVM writes a log entry into the transaction's receipt: up to four 32-byte topics plus an arbitrary data blob. eth_getLogs searches those receipts. Nothing about a log can be read from a contract afterwards, which is why event history is the only cheap way to reconstruct what happened on chain.
Topics are positional and mostly hashed. For a normal (non-anonymous) event, topic 0 is the keccak256 of the canonical signature, so Transfer(address,address,uint256) is always 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef. Indexed value-type arguments fill topics 1 through 3 left-padded to 32 bytes; indexed dynamic types (string, bytes, arrays, structs) are stored as the keccak of their contents, so you can filter on them but never recover the original value. Everything not marked indexed is ABI-encoded into data.
Serving the query is the expensive part. Every block header carries a 2048-bit logsBloom; the client walks the headers in your range, tests the bloom for your address and topics, then opens the receipts of the candidate blocks for exact matching. Blooms produce false positives but never false negatives, so a rare topic over a wide range still costs a full header walk plus receipt reads on the false hits. That work, not the size of the answer, is why hosted endpoints put a ceiling on the range.
Parameters
| # | Name | Type | Description |
|---|---|---|---|
| 1 | filterObject | object | fromBlock and toBlock (hex block numbers or latest, safe, finalized, earliest), address (one address or an array of them), topics (positional array of up to four entries, each null, a topic hash, or an array of hashes meaning OR), and blockHash to pin exactly one block. |
Topic matching is positional: [A] matches any log whose first topic is A regardless of the rest; [A, null, B] pins topics 0 and 2 and leaves topic 1 free; [[A, B]] matches either A or B in position 0. blockHash cannot be combined with fromBlock or toBlock, and it is the reorg-safe way to ask for a single block, because a block number can point at different blocks before and after a reorg.
What it returns
An array of log objects, ordered by block and then by position within the block. Each carries address, topics, data, blockNumber, blockHash, transactionHash, transactionIndex, logIndex and removed. logIndex counts across the whole block rather than per transaction, so the pair (blockHash, logIndex) is the stable primary key for an indexer. Recent Geth also stamps each log with blockTimestamp, which saves a block lookup per log.
No match returns an empty array rather than an error, and that is a trap: a mistyped topic hash, the wrong contract address and a genuinely quiet range all look identical. When a query unexpectedly returns nothing, verify the topic hash and re-run with the address filter removed before assuming the range is empty.
Example response
{
"jsonrpc": "2.0",
"id": 1,
"result": [
{
"address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
"topics": [
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
"0x00000000000000000000000098a7f18d4e56cfe84e3d081b40001b3d5bd3eb8b",
"0x00000000000000000000000045312ea0eff7e09c83cbe249fa1d7598c4c8cd4e"
],
"data": "0x0000000000000000000000000000000000000000000000000000000006aff77f",
"blockNumber": "0x18789ac",
"blockHash": "0x7e9ea4877f25847c29ea338043948a8ea43383da1620ace955a20baf667c5ff5",
"transactionHash": "0x2efde06bb1dcf26741f3a7c1385d5a62ad2554523322e9dd53f1635ba6add6d1",
"transactionIndex": "0x1",
"logIndex": "0x5",
"removed": false
}
]
}One USDC Transfer, trimmed to a single entry. Topic 1 is the sender and topic 2 the recipient, both left-padded addresses; the non-indexed amount sits in data (0x6aff77f is 112,195,455 raw units, 112.19 USDC at 6 decimals). Running the example against the head block returns however many transfers that block happened to contain.
eth_getLogs with ethers.js
import { JsonRpcProvider, Interface, id } from "ethers";
const provider = new JsonRpcProvider("https://ethereum-rpc.publicnode.com");
const iface = new Interface([
"event Transfer(address indexed from, address indexed to, uint256 value)",
]);
const head = await provider.getBlockNumber();
const logs = await provider.getLogs({
address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
topics: [id("Transfer(address,address,uint256)")],
fromBlock: head - 9, // keep windows small and walk them
toBlock: head,
});
for (const log of logs) {
const parsed = iface.parseLog(log);
if (parsed) console.log(parsed.args.from, "->", parsed.args.to, parsed.args.value);
}Gotchas and common errors
The range limit is provider policy, not protocol
The JSON-RPC spec sets no maximum range, so every endpoint invents its own and phrases it differently: "query returned more than 10000 results", "Exceed maximum block range: 5000", "eth_getLogs is limited to a 1024 block range", or a plain timeout. There are really two ceilings, one on how many blocks you may span and one on how many logs may come back, and a single block on a busy contract can breach the second one on its own. The robust pattern is adaptive: try a wide window, and on failure halve it and retry until the chunk succeeds, then grow again. BLAZED.sh currently serves eth_getLogs in windows of up to 1,000 blocks per request.
Old ranges are gated more often than they are unavailable
Logs live in receipts rather than in state, so a node that keeps its receipt history can answer a query about a year-old block without archive state. What stops you is usually commercial: many shared endpoints classify anything outside the last handful of blocks as an archive request and refuse it on the free tier. Clients configured to prune receipt or transaction history are the other cause. On BLAZED.sh the standard plans run tip-of-chain nodes and archive access is an Enterprise feature.
removed: true is a reorg telling you to roll back
When a block is reorged out, its logs are re-delivered with removed set to true so consumers can undo them. Batch queries against settled history rarely see this; anything reading near the head will. Index against (blockHash, logIndex), keep a confirmation buffer or read from the finalized tag, and handle removed entries as deletions rather than filtering them out.
There is no such thing as a pending log
Logs only exist once a transaction is executed in a block, so fromBlock: "pending" gets you nothing useful; a transaction sitting in the mempool has emitted nothing yet. For a live feed, subscribe with eth_subscribe over WebSocket and reconcile gaps with eth_getLogs on reconnect; to predict the logs a transaction would emit, simulate it with debug_traceCall and the callTracer's withLog option.
Signature strings must be canonical
topic 0 is keccak256 over the exact canonical signature: no parameter names, no spaces, no aliases. Transfer(address,address,uint256) hashes correctly; Transfer(address from, address to, uint256 value) and Transfer(address,address,uint) do not. Let a library derive it (ethers' id() or Interface) rather than typing hashes by hand, and remember that anonymous events have no topic 0 at all, so their first indexed argument sits in position 0.
Wide queries are big responses
A month of Transfer events from a busy token is megabytes of JSON. That is slow to serialize on the node, slow to parse in your process, and on any per-request billing model it is the part that costs real money. Chunking is not only about staying under range caps; it also keeps individual responses small enough to stream into a database instead of buffering the whole backfill in memory.
What eth_getLogs costs on BLAZED.sh
eth_getLogs costs 1 credit per request on BLAZED.sh, the standard rate. The number that matters for indexers is the second one: responses over 100KB add 50 credits per MB, and a wide range over an active contract is exactly the response shape that gets there. Chunking keeps each response small and therefore usually under the surcharge, at the cost of more requests. A single request currently covers up to 1,000 blocks, and because your code runs on the node host, every chunk is a sub-10ms local round-trip rather than a queued call into a shared gateway.
See the full credit price listeth_getLogs: frequently asked questions
What is the maximum block range for eth_getLogs?
The protocol defines none; each provider sets its own, commonly between 1,000 and 10,000 blocks, often combined with a cap of 10,000 returned logs. BLAZED.sh currently serves windows of up to 1,000 blocks per request. Write your fetcher to halve the window and retry on failure so it survives whichever endpoint it runs against.
Why does eth_getLogs return "query returned more than 10000 results"?
Your filter matched more logs than the endpoint will return in one response. Narrowing the block range is the usual fix; if a single block already exceeds the cap, narrow the filter instead by pinning more topics or querying one address at a time.
Why is my topic filter returning an empty array?
Most often the topic hash is wrong: it must be keccak256 of the canonical signature with no parameter names or spaces, and uint256 rather than uint. Indexed string and bytes arguments are stored hashed, so filtering on them means filtering on keccak of the value. Drop the topics and keep only the address to confirm the contract is emitting at all.
Do I need an archive node for eth_getLogs?
Not for the data itself, since logs come from receipts rather than from state. In practice many shared endpoints gate historical queries behind a paid tier, and some clients prune receipt history. On BLAZED.sh, standard plans run tip-of-chain nodes and archive access is an Enterprise feature.
How do I follow events in real time instead of polling?
Open a WebSocket and eth_subscribe to logs with the same filter shape; the node pushes matches as blocks arrive. Keep the last processed block number so you can backfill the gap with eth_getLogs after a reconnect, since a dropped connection silently ends the subscription.
Call eth_getLogs from the node itself
Deploy your container or script next to a synced Ethereum node. No rate limits, no compute units, sub-10ms local RPC.