Logo
New RPC users get 35% off their first monthView the offer
OnFinality Learn
Network & Protocol Guides12 min read

Ethereum eth_getLogs: Filtering Event Logs by Address and Topics

Learn how eth_getLogs topic filters work, how to query ERC-20 Transfer events efficiently, and how to avoid common pitfalls.

TL;DR

eth_getLogs lets you retrieve Ethereum event logs by filtering on the emitting contract address and up to four topic positions. The first topic is always the event signature hash; the next three correspond to indexed parameters, which are stored as 32-byte hashes. To query efficiently, you must understand how indexed parameters are encoded, how to combine filters with OR logic, and how to page through large block ranges because providers impose their own limits. This guide explains the mechanism, provides runnable examples, and offers a troubleshooting checklist.

Direct Answer: How to Filter Event Logs Correctly

To filter Ethereum event logs with eth_getLogs, you send a JSON-RPC request with an address (or array of addresses) and a topics array. The topics array can have up to four entries, each matching the corresponding topic position in the log. The first topic is the event signature hash (e.g., keccak256("Transfer(address,address,uint256)")), and the next three correspond to indexed parameters. Use null to match any value in a position, or an array to OR multiple values within that position. For example, to get all ERC-20 Transfer events from a specific token where the sender is a particular address, you set the first topic to the Transfer signature hash and the second topic to the left-padded 32-byte address of the sender. This guide walks through the mechanics, provides runnable examples, and explains how to handle provider limits and pagination.

If you are new to Ethereum RPC, see the Ethereum network overview and the OnFinality Learn hub for foundational context.

How Event Logs and Topics Work Under the Hood

When a smart contract emits an event, the Ethereum Virtual Machine (EVM) records a log entry in the transaction receipt. Each log has an emitter address (the contract that emitted it), a topics array, and a data field. The topics array contains up to four 32-byte values: the first is always the event signature hash, computed as keccak256("EventName(type1,type2,...)") where types are the canonical Solidity types (e.g., address, uint256, bool). The remaining three topics correspond to indexed parameters, in the order they appear in the event declaration. Indexed parameters are stored as 32-byte values: for value types like address and uint256, the value is left-padded with zeros to 32 bytes; for reference types like string, bytes, and arrays, the value is the keccak256 hash of the actual data. Non-indexed parameters are ABI-encoded and concatenated in the data field.

This encoding is specified in the Solidity documentation on events and the Ethereum Execution API specification for eth_getLogs. Understanding this is crucial because you cannot filter on non-indexed parameters directly; you must decode the data field after retrieval.

The eth_getLogs method accepts an address parameter that can be a single address or an array of addresses (OR logic). The topics parameter is an array of up to four filter entries. Each entry can be a single 32-byte value, an array of 32-byte values (OR within that position), or null to match any value. The block range is specified with fromBlock and toBlock, which can be a hex block number or a tag like latest, earliest, or pending. If omitted, the range defaults to latest for both, meaning only logs from the most recent block are returned—this is a common pitfall.

For a deeper dive into how logs are stored and why scanning is slow, see the Ethereum Stack Exchange discussion as an independent reference.

  • Logs are stored in the transaction receipt, not in the contract storage.
  • The first topic is always the event signature hash.
  • Indexed parameters are limited to three per event.
  • Non-indexed parameters are in the data field and cannot be filtered.
  • Addresses in topics are left-padded to 32 bytes (e.g., 0x0000...0000abc...).

Building a Correct Filter for ERC-20 Transfer Events

Let's construct a filter for ERC-20 Transfer events. The event signature is Transfer(address indexed from, address indexed to, uint256 value). The first topic is keccak256("Transfer(address,address,uint256)"), which is 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef. The second topic is the from address (left-padded), and the third is the to address. The value is not indexed, so it appears in the data field.

To get all transfers from a specific sender for a specific token, you would set the address to the token contract, the first topic to the signature hash, and the second topic to the sender's address padded to 32 bytes. For example, to get transfers from 0x1234... on the USDC contract, the second topic would be 0x0000000000000000000000001234... (with the address right-aligned).

If you want to filter on both from and to, you can provide an array for the second topic to OR multiple senders, or use null to match any. For instance, to get transfers either from or to a specific address, you would need two separate calls because the from and to are in different topic positions and cannot be OR'd across positions.

Here's a concrete example using curl to query the Ethereum mainnet (replace the RPC URL with your provider's endpoint, e.g., from the API service):

curl -X POST https://your-rpc-endpoint -H "Content-Type: application/json" --data '{
  "jsonrpc": "2.0",
  "method": "eth_getLogs",
  "params": [{
    "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
    "topics": [
      "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
      "0x0000000000000000000000001234567890abcdef1234567890abcdef12345678"
    ],
    "fromBlock": "0x1000000",
    "toBlock": "0x1000100"
  }],
  "id": 1
}'

Understanding the Response Shape and Reorg Handling

The response from eth_getLogs is an array of log objects. Each log object contains the following fields: address (the contract that emitted the log), topics (array of 32-byte topics), data (hex-encoded non-indexed data), blockNumber (hex), transactionHash, transactionIndex, blockHash, logIndex, and removed (a boolean indicating whether the log was removed due to a chain reorganization). The removed flag is important for applications that track logs in real time: if a log appears with removed: true, it means the block was reorged and the log is no longer valid.

Logs are returned in receipt order, but there is no guarantee of global ordering across multiple blocks. If you need to process logs sequentially, you should sort them by blockNumber and logIndex.

Here's an example response for a single log:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": [
    {
      "address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
      "topics": [
        "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
        "0x0000000000000000000000001234567890abcdef1234567890abcdef12345678",
        "0x000000000000000000000000abcdefabcdefabcdefabcdefabcdefabcdefabcd"
      ],
      "data": "0x0000000000000000000000000000000000000000000000000000000000000064",
      "blockNumber": "0x1000100",
      "transactionHash": "0x...",
      "transactionIndex": "0x0",
      "blockHash": "0x...",
      "logIndex": "0x0",
      "removed": false
    }
  ]
}

Performance: Why Wide Range Queries Are Slow and How to Page

Scanning a wide block range with eth_getLogs is slow because the node must iterate over every block in the range, load the block's bloom filter, and check whether the requested address/topics might match. This is a CPU-intensive operation, especially on archive nodes. Providers often impose limits on the number of logs returned or the block span per request to protect their infrastructure. These limits are documented / varies by provider—you should check your provider's documentation (e.g., Alchemy's eth_getLogs documentation or Infura's documentation as independent references).

To handle large queries, you should paginate by chunking the block range. A common strategy is to query in fixed-size chunks (e.g., 10,000 blocks) and then, if the response is full, continue from the last block number you received. However, because logs are not guaranteed to be ordered, a safer approach is to use the toBlock of the current chunk as the fromBlock of the next chunk, but subtract one to avoid duplicates. Alternatively, you can use the blockNumber of the last log returned as the next fromBlock, but this can miss logs if the response is truncated mid-block.

The following Node.js script demonstrates a chunked query approach. It uses the fetch API and is designed to be run by the reader to measure their own provider's limits. Fill in the results table below with your observations.

  • Chunk size: start with 10,000 blocks and adjust based on provider limits.
  • If you receive an error indicating too many results, reduce the chunk size.
  • Always handle the removed flag if you are processing logs in real time.
  • For continuous monitoring, consider using eth_newFilter and eth_getFilterChanges instead of repeated eth_getLogs calls.
const rpcUrl = 'https://your-rpc-endpoint';

async function getLogs(fromBlock, toBlock) {
  const response = await fetch(rpcUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      jsonrpc: '2.0',
      method: 'eth_getLogs',
      params: [{
        address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
        topics: ['0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'],
        fromBlock: '0x' + fromBlock.toString(16),
        toBlock: '0x' + toBlock.toString(16)
      }],
      id: 1
    })
  });
  const data = await response.json();
  if (data.error) throw new Error(data.error.message);
  return data.result;
}

async function fetchAllLogs(startBlock, endBlock, chunkSize) {
  let allLogs = [];
  let currentStart = startBlock;
  while (currentStart <= endBlock) {
    const currentEnd = Math.min(currentStart + chunkSize - 1, endBlock);
    const logs = await getLogs(currentStart, currentEnd);
    allLogs = allLogs.concat(logs);
    console.log(`Fetched ${logs.length} logs from block ${currentStart} to ${currentEnd}`);
    if (logs.length === 0) {
      // No logs in this chunk, move to next
      currentStart = currentEnd + 1;
    } else {
      // Continue from the last block number to avoid missing logs at the boundary
      const lastBlock = parseInt(logs[logs.length - 1].blockNumber, 16);
      currentStart = lastBlock + 1;
    }
  }
  return allLogs;
}

// Example: fetch logs from block 16,000,000 to 16,100,000 with 10,000 block chunks
fetchAllLogs(16000000, 16100000, 10000).then(logs => {
  console.log(`Total logs fetched: ${logs.length}`);
}).catch(err => console.error(err));

Results Table: Measure Your Provider's Limits

Run the script above with different chunk sizes and record the results. This will help you understand your provider's behavior and tune your pagination strategy. The table below is for you to fill in.

| Chunk Size (blocks) | Number of Logs Returned | Error or Limit Hit? | Time Taken (s) |
|---------------------|-------------------------|---------------------|----------------|
| 10,000              |                         |                     |                |
| 5,000               |                         |                     |                |
| 1,000               |                         |                     |                |

Troubleshooting Checklist: Common Pitfalls and Fixes

Even experienced developers make mistakes when using eth_getLogs. Here is a checklist of common issues and how to fix them.

  • Empty result? Check that you specified fromBlock and toBlock. If omitted, the default is latest for both, which returns logs only from the latest block.
  • Wrong topic hash? Ensure the event signature is exactly as declared, including parameter types and no spaces. Use keccak256 to compute the hash. For example, Transfer(address,address,uint256) not Transfer(address, address, uint256).
  • Address not matching? Remember that indexed address parameters are left-padded to 32 bytes. The topic should be 0x000000000000000000000000 followed by the 20-byte address (without 0x).
  • Too many results? Reduce the block range or use more specific topics. If you get an error like 'query returned more than 10000 results', you must paginate.
  • Logs missing after reorg? Check the removed flag. If removed: true, the log is no longer valid and should be discarded.
  • Non-indexed parameter filtering? You cannot filter on non-indexed parameters. Retrieve the logs and decode the data field using an ABI decoder.
  • Provider-specific limits? Consult your provider's documentation for maximum block range and log count. These limits are documented / varies by provider.

Limitations and Tradeoffs: Indexed vs Non-Indexed, Storage vs Retrieval

When designing smart contracts, you must decide which event parameters to mark as indexed. Indexed parameters allow efficient filtering but are limited to three per event and incur additional gas costs. Non-indexed parameters are cheaper but cannot be filtered on-chain; you must retrieve and decode them. This tradeoff is critical for applications that rely on event logs for indexing or analytics.

Another limitation is that eth_getLogs does not guarantee a global order across blocks. If you need to process logs in order, you must sort them by blockNumber and logIndex. Additionally, logs are not stored permanently on-chain; they are pruned from full nodes after a certain period unless you use an archive node. For historical queries, you need an archive node, as explained in our guide on querying Ethereum historical state over RPC.

For real-time monitoring, eth_newFilter and eth_getFilterChanges are more efficient than polling eth_getLogs because they only return new logs since the last poll. However, filters are stateful and may be dropped by the node after a period of inactivity. For one-off historical queries, eth_getLogs is the right tool.

Finally, be aware that eth_getLogs can be expensive on the node side. If you are making many queries, consider using a dedicated RPC provider like OnFinality's API service to handle the load. See our RPC pricing for details.

  • Indexed parameters: up to 3, filterable, higher gas cost.
  • Non-indexed parameters: unlimited, not filterable, lower gas cost.
  • Use eth_newFilter for continuous monitoring, eth_getLogs for one-off queries.
  • Archive nodes are required for historical logs beyond the pruning window.

Never Worry about Infrastructure Again

OnFinality takes away the heavy lifting of DevOps so you can build smarter and faster.

Get Started