Custom Tables
Key Features
- Upsert, Insert, Update, Delete - Full CRUD operations on your custom tables
- Computed Values - Arithmetic expressions like
$amount * 2,$price / $quantity, or10 ^ $decimals - Arithmetic with View Calls - Combine math with on-chain data:
($amount * $call($oracle, "getPrice()")) / (10 ^ $call($token, "decimals()")) - String Templates - Concatenate fields with
"$from-$to"or"Pool: $token0/$token1" - Conditional Logic - Filter with
if: "$value > 0 && $from != 0x000...", negate with!($condition) - Array Iteration - Process batch events (ERC1155
TransferBatch) withiterate - Array Indexing - Access specific elements with
$ids[0]or struct fields with$transfers[0].amount - View Calls - Fetch on-chain data with
$call($contract, "balanceOf(address)", $holder), access tuple returns with[0]or.fieldName - Cron Triggers - Schedule operations with
interval: 5mor cron expressions for periodic updates - Global Tables - Single-row aggregates (total supply, TVL)
- Cross-Chain - Aggregate data across multiple networks
- Transaction Metadata - Access
$rindexer_block_number,$rindexer_tx_hash,$rindexer_contract_address, etc. - Constants - Define reusable values with
$constant(name), including network-scoped constants for cross-chain configurations - Schema Migration - Auto-detect and apply column changes when your YAML evolves
The Problem
Traditional indexing logs every event as a new database row:
Transfer: Alice → Bob, 100 USDC → Row 1
Transfer: Bob → Carol, 50 USDC → Row 2
Transfer: Carol → Alice, 25 USDC → Row 3To get Alice's current balance, you need to query and aggregate all these rows. For popular tokens, that's millions of rows to process.
The Solution
With Custom Tables, you maintain the current state directly:
Transfer: Alice → Bob, 100 USDC → Alice: 900, Bob: 100
Transfer: Bob → Carol, 50 USDC → Bob: 50, Carol: 50
Transfer: Carol → Alice, 25 USDC → Alice: 925, Carol: 25One row per address. Instant balance lookups. No aggregation needed.
Quick Start
Here's a complete example that tracks ERC20 token balances:
name: USDCIndexer
project_type: no-code
networks:
- name: ethereum
chain_id: 1
rpc: https://mainnet.gateway.tenderly.co
storage:
postgres:
enabled: true
contracts:
- name: USDC
details:
- network: ethereum
address: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"
start_block: 18600000
abi: ./abis/ERC20.json
tables:
- name: balances
columns:
- name: holder
- name: balance
default: "0"
events:
- event: Transfer
operations:
# Credit the recipient
- type: upsert
where:
holder: $to
if: "$to != 0x0000000000000000000000000000000000000000"
set:
- column: balance
action: add
value: $value
# Debit the sender
- type: upsert
where:
holder: $from
if: "$from != 0x0000000000000000000000000000000000000000"
set:
- column: balance
action: subtract
value: $valueResult: A balances table with one row per holder, instantly queryable.
This example demonstrates the core concepts:
- columns - Define what data you're storing
- events - Map contract events to table operations
- where - Identify which row to update (becomes the primary key)
- if - Filter which events to process
- set - Define how to update columns
Let's dive deeper into each of these.
Table Configuration
name
The table name. Will be created as {indexer}_{contract}_{name} in the database.
tables:
- name: balances # Creates: usdcindexer_usdc_balancesglobal
When true, creates a single row per network - perfect for counters and aggregate metrics.
No where clause needed since the primary key is just network.
tables:
- name: metrics
global: true
columns:
- name: transfer_count
type: uint256
default: "0"
events:
- event: Transfer
operations:
- type: upsert
set:
- column: transfer_count
action: increment # Adds 1 each timecross_chain
When true, aggregates data across ALL networks. The network column is not created,
so data from Ethereum, Arbitrum, Optimism, etc. all contribute to the same rows.
tables:
- name: global_supply
cross_chain: true
global: true
columns:
- name: total
type: uint256
default: "0"timestamp
When true, adds the rindexer_block_timestamp column to the table. By default, this column
is not created to optimize performance.
tables:
- name: balances
timestamp: true
columns:
- name: holder
- name: balance
default: "0"Some RPC nodes don't include block timestamps in event metadata, requiring an additional RPC call to fetch the block and extract its timestamp. This can significantly impact indexing performance, especially for high-volume indexers.
columns
Define the columns in your table.
| Property | Required | Description |
|---|---|---|
name | Yes | Column name |
type | No | Data type (auto-inferred from event ABI if not specified) |
default | No | Default value for new rows |
nullable | No | Whether column allows NULL values (default: false = NOT NULL) |
Type Inference Rules
Column types are automatically inferred in these cases - no type: needed:
| Value Source | Example | Inferred Type |
|---|---|---|
| Event field | $from, $value, $to | From ABI (e.g., address, uint256) |
| Nested event field | $data.amount | From ABI |
| Transaction metadata | $rindexer_block_number | uint64 |
| Transaction metadata | $rindexer_tx_hash, $rindexer_block_hash | string |
| Transaction metadata | $rindexer_contract_address | address |
| Transaction metadata | $rindexer_block_timestamp | timestamp (requires timestamp: true) |
Default value "0" | default: "0" | uint256 |
| Default value boolean | default: "true" | bool |
| Default value address | default: "0x000..." | address |
You must specify type: in these cases:
| Value Source | Example | Why |
|---|---|---|
| View calls | $call($addr, "balanceOf(address)", $holder) | Return type unknown |
| Computed/arithmetic | $amount * 2, $a + $b, 10 ^ $decimals | Result type ambiguous |
| Arithmetic + view calls | ($amount * $call(...)) / (10 ^ $call(...)) | Complex expression |
| String templates | "$from-$to" | Always produces string |
| Literal values | "global", "1000" | No type context |
| No value reference | Column not used in set or where | Nothing to infer from |
columns:
# ✅ Type inferred from event ABI - $to is address, $value is uint256
- name: holder # type: address (inferred from $to)
- name: balance # type: uint256 (inferred from $value)
default: "0"
# ✅ Type inferred from metadata
- name: last_block # type: uint64 (inferred from $rindexer_block_number)
- name: tx_hash # type: string (inferred from $rindexer_tx_hash)
# ⚠️ Must specify type - view call return type unknown
- name: token_symbol
type: string # Required! $call() can't infer type
- name: token_decimals
type: uint8 # Required!
# ⚠️ Must specify type - arithmetic result
- name: doubled_amount
type: uint256 # Required! $value * 2 needs explicit type
# ⚠️ Must specify type - string template
- name: pair_id
type: string # Required! "$token0-$token1" is a string
# ⚠️ Must specify type - literal value
- name: status
type: string # Required! "active" is a literalSupported Types
| Type | Description | PostgreSQL | ClickHouse |
|---|---|---|---|
address | Ethereum address | CHAR(42) | FixedString(42) |
string | Text | TEXT | String |
bool | Boolean | BOOLEAN | Bool |
uint8 - uint64 | Unsigned integers | BIGINT | UInt64 |
uint128 - uint256 | Large unsigned integers | NUMERIC | UInt256 |
int8 - int64 | Signed integers | BIGINT | Int64 |
int128 - int256 | Large signed integers | NUMERIC | Int256 |
bytes | Dynamic bytes | BYTEA | String |
bytes32 | Fixed 32 bytes | BYTEA | FixedString(66) |
timestamp | Date/time | TIMESTAMPTZ | DateTime |
address[] | Array of addresses | TEXT[] | Array(String) |
uint256[] | Array of uint256 | TEXT[] | Array(String) |
bytes32[] | Array of bytes32 | TEXT[] | Array(String) |
Array Types
Arrays from event parameters are supported and stored as database arrays:
columns:
- name: participants
type: address[]
- name: amounts
type: uint256[]- Storing entire arrays from events (e.g.,
$addresses,$values) - Querying arrays via GraphQL
- Address arrays are stored efficiently
- Iterating over arrays with
iterate(see Array Iteration) - Accessing individual elements with
$array[0]syntax (see Array Indexing)
- Cannot use arrays in
whereclauses - Arrays can't be part of primary keys (useiterateto expand arrays into individual rows)
Events & Operations
events
Maps contract events to table operations.
events:
- event: Transfer # Must match the ABI event name
operations:
- ...operations
Each operation defines what happens when an event is received.
type
| Type | Description | Use Case |
|---|---|---|
upsert | Insert new row or update existing | Most common - balances, ownership |
insert | Insert a new row (no conflict handling) | Time-series data, price history, logs |
update | Update existing row only (no insert) | Modify existing records |
delete | Remove the row | Clean up data |
where
Identifies which row to affect. Maps column names to values.
where:
holder: $to # Column "holder" = event field "to"
token_id: $tokenId # Column "token_id" = event field "tokenId"if
Skip events that don't match the condition. Supports comparison and logical operators.
# Skip zero address
if: "$to != 0x0000000000000000000000000000000000000000"
# Multiple conditions
if: "$value > 0 && $from != 0x0000000000000000000000000000000000000000"
# Only update if new value is greater than existing
if: "$value > @balance"set
Define what columns to update and how.
set:
- column: balance
action: add
value: $valueSet Actions
| Action | Description | Example Result |
|---|---|---|
set | Replace value | balance = 100 |
add | Add to existing | balance = balance + 50 |
subtract | Subtract from existing | balance = balance - 50 |
max | Keep the larger value | high = max(high, 150) |
min | Keep the smaller value | low = min(low, 50) |
increment | Add 1 | count = count + 1 |
decrement | Subtract 1 | count = count - 1 |
Value References
Event Fields
Reference any field from the event using $fieldName:
value: $from # Sender address
value: $to # Recipient address
value: $value # Transfer amount
value: $tokenId # NFT token IDTuples and Structs (Nested Fields)
Many events contain tuple or struct fields with nested data. Access nested fields using dot notation:
value: $data.amount # Access 'amount' inside 'data' tuple
value: $order.maker # Access 'maker' inside 'order' struct
value: $info.token.address # Access deeply nested fieldsFor an event like:
struct OrderInfo {
address maker;
address taker;
uint256 amount;
}
event OrderFilled(bytes32 indexed orderId, OrderInfo info);Access the nested fields:
where:
order_id: $orderId
set:
- column: maker
action: set
value: $info.maker
- column: taker
action: set
value: $info.taker
- column: amount
action: set
value: $info.amountArray Indexing
Access specific elements from array fields using bracket notation:
value: $ids[0] # First element of 'ids' array
value: $values[1] # Second element of 'values' array
value: $data.tokens[0] # First element of nested 'tokens' arrayThis is useful when you only need specific elements from an array, such as the first token in a batch.
Post-Array Field Access
For arrays of structs, you can access fields within each element:
value: $transfers[0].amount # 'amount' field of first transfer
value: $orders[1].maker # 'maker' field of second order
value: $swaps[0].tokenIn # 'tokenIn' field of first swapArray Iteration (Batch Events)
For events with parallel arrays (like ERC1155 TransferBatch), use iterate to process each array element as a separate operation:
events:
- event: TransferBatch
iterate: # Iterate over parallel arrays
- "$ids as token_id" # Bind each id to 'token_id'
- "$values as amount" # Bind each value to 'amount'
operations:
- type: upsert
where:
holder: $to
token_id: $token_id # Use the iterated value
if: "$to != 0x0000000000000000000000000000000000000000"
set:
- column: balance
action: add
value: $amount # Use the iterated valueiteratetakes a list of array bindings in the format"$arrayField as alias"- All arrays must have the same length (they're processed in parallel)
- For each index, the operations are executed with the aliased values bound
- Use the aliases (
$token_id,$amount) inwhere,if, andsetclauses
Transaction Metadata
Access transaction and block information:
value: $rindexer_block_number # Block number
value: $rindexer_block_timestamp # Block timestamp (requires timestamp: true on table)
value: $rindexer_tx_hash # Transaction hash
value: $rindexer_block_hash # Block hash
value: $rindexer_contract_address # Contract that emitted the event
value: $rindexer_log_index # Log index in transaction
value: $rindexer_tx_index # Transaction index in blockView Calls (On-Chain Data)
Call view functions on smart contracts to fetch additional data not available in events:
value: $call($rindexer_contract_address, "balanceOf(address)", $holder)
value: $call($token, "decimals()")
value: $call($token, "totalSupply()")
value: $call(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48, "allowance(address,address)", $owner, $spender)Syntax: $call(contract_address, "function_signature", arg1, arg2, ...)
- contract_address: A literal address or
$fieldreference from the event - function_signature: The function signature in Solidity format (e.g.,
"balanceOf(address)") - args: Arguments to pass to the function (can be
$fieldreferences or literals)
Accessing Tuple/Struct Returns
Many Solidity view functions return multiple values (tuples) or structs. rindexer provides two ways to access specific elements from these returns.
Quick Reference
| Approach | Syntax | When to Use |
|---|---|---|
| Position-based | $call(...)[0] | Quick, no setup needed |
| Named fields | $call(... returns (type name, ...)).fieldName | Self-documenting, readable YAML |
Position-Based Access [index]
Use [index] after the call to access tuple elements by their position (0-indexed):
# Uniswap V2 getReserves() returns (uint112, uint112, uint32)
# Position: [0] = reserve0, [1] = reserve1, [2] = blockTimestampLast
value: $call($pool, "getReserves()")[0] # Get reserve0
value: $call($pool, "getReserves()")[1] # Get reserve1
value: $call($pool, "getReserves()")[2] # Get blockTimestampLastPros: Simple, no extra typing Cons: Have to remember what each position means
Named Field Access .fieldName
Add returns (type name, type name, ...) to your function signature to enable .fieldName access:
# Same getReserves() call, but with named access
value: $call($pool, "getReserves() returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast)").reserve0
value: $call($pool, "getReserves() returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast)").reserve1Pros: Self-documenting, YAML is readable without looking up ABI Cons: More verbose
Literal Values
Use fixed values:
value: "0" # Number as string
value: "default" # String identifier
value: 0x0000000000000000000000000000000000000000 # AddressNull Values
Set a column to SQL NULL using $null:
value: $null # Explicit SQL NULLConditional Values
Use $if(condition, trueValue, falseValue) to conditionally set a value based on an expression:
value: $if($amount > 0, $amount, $null) # Use amount if positive, else null
value: $if($from == 0x0000000000000000000000000000000000000000, "mint", "transfer")
value: $if($value >= 1000000, $value, $null) # Only store if value >= 1MSyntax: $if(condition, valueIfTrue, valueIfFalse)
- condition: A boolean expression using the same syntax as
if:filters - valueIfTrue: Value to use when condition is true (can be
$field,$null, literal, etc.) - valueIfFalse: Value to use when condition is false
| Operator | Meaning |
|---|---|
== | Equal |
!= | Not equal |
> | Greater than |
>= | Greater or equal |
< | Less than |
<= | Less or equal |
&& | Logical AND |
|| | Logical OR |
columns:
- name: holder
- name: transfer_type
type: string
- name: significant_amount
type: uint256
nullable: true # Allows $null
events:
- event: Transfer
operations:
- type: upsert
where:
holder: $to
set:
# Classify transfer type based on addresses
- column: transfer_type
action: set
value: $if($from == 0x0000000000000000000000000000000000000000, "mint", $if($to == 0x0000000000000000000000000000000000000000, "burn", "transfer"))
# Only store amount if it's significant (>= 1000), else null
- column: significant_amount
action: set
value: $if($value >= 1000, $value, $null)Arithmetic Expressions
Perform calculations using event fields:
value: $value * 2 # Multiply by constant
value: $amount + $fee # Add two event fields
value: $amount0 - $amount1 # Subtract fields
value: $ratio / 100 # Divide by constant
value: $amount * $price # Multiply two fields
value: 10 ^ $decimals # Exponentiation (10 to the power of decimals)
value: $base ^ 18 # Raise field to a powerSupported operators: +, -, *, /, ^ (exponentiation)
Arithmetic with View Calls
You can combine arithmetic expressions with $call() to compute values that depend on on-chain data.
This is powerful for computing USD values, normalized amounts, and other derived metrics.
# Compute USD value: (amount * oracle_price) / 10^decimals
value: ($amount * $call($constant(oracle), "getAssetPrice(address)", $token)) / (10 ^ $call($token, "decimals()"))
# Normalize amount by fetching decimals on-chain
value: $rawAmount / (10 ^ $call($tokenAddress, "decimals()"))
# Compute with multiple view calls
value: $call($pool, "getReserves()")[0] * $call($oracle, "getPrice()")- All
$call()expressions in the arithmetic are resolved first (fetched from the blockchain) - The returned values replace the
$call()placeholders - The arithmetic expression is then evaluated with the resolved values
Real-World Example: Liquidation USD Value
Here's a complete example from an Aave liquidation indexer that computes the USD value of liquidated collateral:
constants:
oracle:
ethereum: "0x54586bE62E3c3580375aE3723C145253060Ca0C2"
arbitrum: "0xb56c2F0B653B2e0b10C9b928C8580Ac5Df02C7C7"
contracts:
- name: AaveV3Pool
abi: ./abis/aave-pool.json
details:
- network: ethereum
address: "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2"
start_block: 24263944
tables:
- name: liquidations
columns:
- name: borrower
- name: collateral_asset
- name: collateral_amount_raw
- name: total_usd_value
type: uint256 # Result has 8 decimal precision from oracle
events:
- event: LiquidationCall
operations:
- type: insert
set:
- column: borrower
action: set
value: $user
- column: collateral_asset
action: set
value: $collateralAsset
- column: collateral_amount_raw
action: set
value: $liquidatedCollateralAmount
- column: total_usd_value
action: set
# Formula: (amount * price) / 10^decimals
# - $liquidatedCollateralAmount: raw amount from event
# - getAssetPrice(): returns price with 8 decimals
# - decimals(): returns token decimals (e.g., 18 for WETH)
# Result: USD value with 8 decimal precision
value: ($liquidatedCollateralAmount * $call($constant(oracle), "getAssetPrice(address)", $collateralAsset)) / (10 ^ $call($collateralAsset, "decimals()"))$liquidatedCollateralAmount- raw collateral amount from the event (e.g., 1000000000000000000 for 1 WETH)$call($constant(oracle), "getAssetPrice(address)", $collateralAsset)- USD price from Aave oracle (8 decimals, e.g., 200000000000 for $2000)$call($collateralAsset, "decimals()")- token decimals (e.g., 18 for WETH)10 ^ decimals- the divisor to normalize the amount
Result: (1e18 * 2000e8) / 10^18 = 2000e8 = $2000 with 8 decimal precision
String Templates
Embed event fields into strings using $fieldName within any text:
value: "$from-$to" # Concatenate two addresses with dash
value: "Pool: $token0/$token1" # Create pool identifier
value: "Transfer from $from" # Prefix text with field
value: "Block $rindexer_block_number: $rindexer_tx_hash" # Mix tx metadata with textConstants
Define reusable values at the manifest level and reference them with $constant(name). Constants are especially powerful for network-scoped configurations where you need different values per network (like oracle addresses, protocol contracts, or fee recipients).
Defining Constants
Add constants at the root level of your rindexer.yaml:
name: MyIndexer
project_type: no-code
constants:
# Simple constant - same value for all networks
fee_recipient: "0x1234567890123456789012345678901234567890"
# Network-scoped constant - different value per network
oracle:
ethereum: "0x54586bE62E3c3580375aE3723C145253060Ca0C2"
arbitrum: "0xbDdE4E4429c6Ef916d2633A2c80E0F6D0F893C44"
optimism: "0x3C19d4C5E0D43d1f7a0f4c8E8d5f6b3a2b1c0d9e"
base: "0x8B4d3e5F6A7c8D9E0F1a2B3c4D5e6F7a8B9c0D1e"
networks:
- name: ethereum
chain_id: 1
rpc: https://mainnet.gateway.tenderly.co
- name: arbitrum
chain_id: 42161
rpc: https://arbitrum.gateway.tenderly.co
# ... more networks
contracts:
# ...Using Constants
Reference constants with $constant(name) anywhere you'd use a value:
tables:
- name: prices
columns:
- name: asset
- name: price_usd
type: int256
events:
- event: CollateralDeposited
operations:
- type: upsert
where:
asset: $collateralAsset
set:
- column: price_usd
action: set
# $constant(oracle) resolves to the network-specific oracle address
value: $call($constant(oracle), "getAssetPrice(address)", $collateralAsset)- When the event is processed on Ethereum,
$constant(oracle)resolves to0x54586bE62E3c3580375aE3723C145253060Ca0C2 - When processed on Arbitrum, it resolves to
0xbDdE4E4429c6Ef916d2633A2c80E0F6D0F893C44 - Simple constants (like
fee_recipient) resolve to the same value on all networks
Where Constants Can Be Used
Constants work in:
- View call contract addresses:
$call($constant(oracle), "getPrice()") - View call arguments:
$call($contract, "allowance(address)", $constant(fee_recipient)) - Direct values:
value: $constant(default_amount) - Where clauses:
where: { recipient: $constant(fee_recipient) }
Condition Expressions
Use the if: field to filter which events trigger operations.
Comparison Operators
| Operator | Meaning | Example |
|---|---|---|
== | Equal | $from == 0x0000... |
!= | Not equal | $to != 0x0000... |
> | Greater than | $value > 0 |
>= | Greater or equal | $value >= 1000000 |
< | Less than | $value < 1000000 |
<= | Less or equal | $value <= 100 |
Logical Operators
| Operator | Meaning | Example |
|---|---|---|
&& | AND | $value > 0 && $from != 0x0000... |
|| | OR | $from == 0x0000... || $to == 0x0000... |
! | NOT | !($paused == true) |
NOT Operator
The ! operator negates an expression. Use it to invert the result of a condition or group of conditions:
# Skip if paused
if: "!($paused == true)"
# Skip if either frozen or paused
if: "!($frozen == true || $paused == true)"
# Only process if NOT a mint AND NOT a burn
if: "!($from == 0x0000000000000000000000000000000000000000) && !($to == 0x0000000000000000000000000000000000000000)"Event vs Table References
| Syntax | Meaning | When to Use |
|---|---|---|
$value | Incoming event value | Compare event data |
@balance | Current database value | Compare with existing state |
if: "$value > @balance"This is powerful for:
- High water marks (only store if higher)
- Conditional updates (only update if changed)
- Preventing stale data overwrites
Cron Triggers (Scheduled Operations)
In addition to event-driven operations, you can trigger table operations on a schedule using cron. This is perfect for:
- Periodic data fetching - Poll on-chain state at regular intervals
- Price feeds - Update prices from oracles every few seconds/minutes
- Snapshots - Record state at fixed intervals
- Heartbeat data - Maintain up-to-date records even when no events occur
Tables can have events, cron, or both - giving you maximum flexibility.
Basic Cron Configuration
tables:
- name: eth_price
columns:
- name: id
type: string
- name: price
type: int256
cron:
- interval: 5s # Run every 5 seconds
operations:
- type: upsert
where:
id: "eth-usd"
set:
- column: price
action: set
value: $call($contract, "latestAnswer()")Schedule Formats
| Format | Example | Description |
|---|---|---|
| Simple interval | 5s, 30s, 5m, 1h, 1d | Fixed time intervals |
| Cron expression | "*/5 * * * *" | Standard cron syntax (every 5 minutes) |
s= seconds (e.g.,30s= every 30 seconds)m= minutes (e.g.,5m= every 5 minutes)h= hours (e.g.,1h= every hour)d= days (e.g.,1d= every day)
Cron Expressions follow standard cron syntax:
┌───────────── minute (0-59)
│ ┌───────────── hour (0-23)
│ │ ┌───────────── day of month (1-31)
│ │ │ ┌───────────── month (1-12)
│ │ │ │ ┌───────────── day of week (0-6, Sunday=0)
│ │ │ │ │
* * * * *Use interval or schedule, not both:
cron:
# Option 1: Simple interval
- interval: 5m
operations: [...]
# Option 2: Cron expression
- schedule: "0 * * * *" # Every hour at minute 0
operations: [...]Available Variables in Cron Operations
Since cron operations don't have event context, only these variables are available:
| Variable | Description |
|---|---|
$call(...) | View function calls (same syntax as events) |
$contract | Contract address from contract details |
$rindexer_block_number | Latest block number at execution time |
$rindexer_timestamp | Current timestamp |
| Literals | String/number values (e.g., "eth-usd", 100) |
Combining Events and Cron
Tables can have both event triggers and cron triggers. This is useful when you want to:
- Update on events (immediate reaction)
- Also update periodically (ensure freshness)
tables:
- name: token_state
columns:
- name: holder
- name: balance
type: uint256
default: "0"
- name: last_checked_balance
type: uint256
default: "0"
# Update balance on Transfer events
events:
- event: Transfer
operations:
- type: upsert
where:
holder: $to
set:
- column: balance
action: add
value: $value
# Also periodically verify balance via view call
cron:
- interval: 1h
operations:
- type: upsert
where:
holder: "0xKnownWhaleAddress"
set:
- column: last_checked_balance
action: set
value: $call($contract, "balanceOf(address)", "0xKnownWhaleAddress")Historical Cron Sync
Just like event indexing supports replaying historical blocks, cron triggers can also sync historical data. This is useful for:
- Building historical price snapshots - Replay oracle prices at past blocks
- Backfilling time-series data - Generate data points at regular block intervals from the past
- Reconstructing historical state - Capture on-chain state at specific historical moments
Configuration
Add start_block, end_block, and optionally block_interval to your cron configuration:
tables:
- name: eth_price_history
columns:
- name: price
type: int256
cron:
- schedule: "*/5 * * * *" # Live schedule (used after historical sync)
start_block: 18000000 # Start historical sync from this block
end_block: 19000000 # Stop at this block (optional)
block_interval: 100 # Execute every 100 blocks (optional)
operations:
- type: insert
set:
- column: price
action: set
value: $call($contract, "latestAnswer()")Fields
| Field | Required | Description |
|---|---|---|
start_block | Yes (for historical) | Block number to begin historical sync from |
end_block | No | Block number to stop at. If omitted, syncs to latest then continues live |
block_interval | No | How many blocks between each execution. Default: 1 (every block) |
Behavior
-
Historical sync first: If
start_blockis specified, rindexer replays the cron operations fromstart_blockforward, executing at eachblock_intervalstep. -
Database state tracking: Progress is saved to the database, so if you restart, it resumes from where it left off (just like event indexing).
-
After historical sync completes:
- If
end_blockis specified → The cron stops completely (no live mode) - If
end_blockis omitted → Switches to live mode using theintervalorschedule
- If
Example: Backfill Oracle Prices Every 100 Blocks
cron:
- interval: 15s # Live: poll every 15 seconds
start_block: 24184625 # Historical: start from this block
block_interval: 100 # Historical: snapshot every 100 blocks
network: ethereum
operations:
- type: insert
set:
- column: price
action: set
value: $call($contract, "latestAnswer()")This will:
- Insert a price snapshot at blocks 24184625, 24184725, 24184825, ... up to the latest block
- Then switch to live mode, inserting every 15 seconds
Full example:
name: Historic
description: Demonstrates cron historical sync - replaying cron operations at past blocks
repository: https://github.com/joshstevens19/rindexer
project_type: no-code
networks:
- name: ethereum
chain_id: 1
rpc: 'RPC'
storage:
postgres:
enabled: true
drop_each_run: true
contracts:
- name: ChainlinkETHUSD
details:
- network: ethereum
address: "0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419"
abi: ./abis/ChainlinkAggregator.abi.json
tables:
- name: eth_price_historical_only
columns:
- name: price
type: int256
cron:
- interval: 15s # Live mode: poll every 15 seconds
start_block: 24184625
block_interval: 100 # Execute every 100 blocks
network: ethereum
operations:
- type: insert
set:
- column: price
action: set
value: $call($contract, "latestAnswer()")Example: Historical-Only Sync (No Live Mode)
For one-time backfills that shouldn't continue running:
cron:
- start_block: 24184625 # Start block
end_block: 24284625 # Stop at this block (required for historical-only)
block_interval: 100 # Every 100 blocks
network: ethereum
operations:
- type: insert
set:
- column: price
action: set
value: $call($contract, "latestAnswer()")When end_block is specified, the cron task stops after reaching that block and does not continue with live polling.
Performance: Adaptive Rate Limiting
rindexer automatically adapts to your RPC node's capabilities:
| Node Type | Expected Speed | Behavior |
|---|---|---|
| Paid/Enterprise | ~300 blocks/sec | Scales up to 100 concurrent requests, 1000-block batches |
| Free Public | ~20 blocks/sec | Automatically scales down when rate limited |
The system starts conservatively and scales up aggressively when the RPC responds quickly. If rate limiting is detected (slow responses), it scales back down and waits before retrying.
Auto-Injected Columns
Every custom table automatically includes these columns - you don't need to define them:
| Column | Type | Description |
|---|---|---|
network | VARCHAR | Network name (omitted if cross_chain: true) |
rindexer_sequence_id | NUMERIC NOT NULL | Unique ID for deterministic ordering |
rindexer_block_number | BIGINT NOT NULL | Block number of the event |
rindexer_block_timestamp | TIMESTAMPTZ NOT NULL | Block timestamp of the event (only if timestamp: true) |
rindexer_tx_hash | CHAR(66) NOT NULL | Transaction hash of the event |
rindexer_block_hash | CHAR(66) NOT NULL | Block hash of the event |
rindexer_contract_address | CHAR(42) NOT NULL | Contract that emitted the event |
These let you track when and where each event originated.
Real-World Examples
NFT Ownership (ERC721)
Track who owns each NFT:
tables:
- name: ownership
columns:
- name: token_id
- name: owner
events:
- event: Transfer
operations:
- type: upsert
where:
token_id: $tokenId
set:
- column: owner
action: set
value: $toResult: An ownership table where you can instantly look up who owns any NFT.
ERC20 Allowances (Approvals)
Track how much each spender is approved to spend on behalf of each owner:
tables:
- name: allowances
columns:
- name: owner
- name: spender
- name: amount
default: "0"
events:
- event: Approval
operations:
- type: upsert
where:
owner: $owner
spender: $spender
set:
- column: amount
action: set # Approvals replace, not add
value: $valueResult: An allowances table with one row per (owner, spender) pair. Query any approval instantly.
ERC1155 Multi-Token Balances (Compound Primary Keys)
ERC1155 tokens require tracking balances per (holder, token_id) combination - a compound primary key:
tables:
- name: balances
columns:
- name: holder
- name: token_id
- name: balance
default: "0"
events:
# Handle single transfers
- event: TransferSingle
operations:
# Credit recipient
- type: upsert
where:
holder: $to
token_id: $id # Compound key: (holder, token_id)
if: "$to != 0x0000000000000000000000000000000000000000"
set:
- column: balance
action: add
value: $value
# Debit sender
- type: upsert
where:
holder: $from
token_id: $id
if: "$from != 0x0000000000000000000000000000000000000000"
set:
- column: balance
action: subtract
value: $value
# Handle batch transfers using iterate
- event: TransferBatch
iterate:
- "$ids as token_id"
- "$values as amount"
operations:
# Credit recipient for each token
- type: upsert
where:
holder: $to
token_id: $token_id
if: "$to != 0x0000000000000000000000000000000000000000"
set:
- column: balance
action: add
value: $amount
# Debit sender for each token
- type: upsert
where:
holder: $from
token_id: $token_id
if: "$from != 0x0000000000000000000000000000000000000000"
set:
- column: balance
action: subtract
value: $amountResult: A balances table with one row per (holder, token_id) pair.
Token Supply Tracking (Mints & Burns)
Track total supply, minted, and burned amounts with a global table:
tables:
- name: supply
global: true # One row per network
columns:
- name: total_supply
type: uint256
default: "0"
- name: total_minted
type: uint256
default: "0"
- name: total_burned
type: uint256
default: "0"
events:
- event: Transfer
operations:
# Mint (from zero address)
- type: upsert
if: "$from == 0x0000000000000000000000000000000000000000"
set:
- column: total_supply
action: add
value: $value
- column: total_minted
action: add
value: $value
# Burn (to zero address)
- type: upsert
if: "$to == 0x0000000000000000000000000000000000000000"
set:
- column: total_supply
action: subtract
value: $value
- column: total_burned
action: add
value: $valueResult: A single row per network with live supply metrics.
Cross-Chain Aggregation
Track total balance across Ethereum, Arbitrum, Optimism, and more:
name: CrossChainUSDC
project_type: no-code
networks:
- name: ethereum
chain_id: 1
rpc: https://mainnet.gateway.tenderly.co
- name: arbitrum
chain_id: 42161
rpc: https://arbitrum.gateway.tenderly.co
- name: optimism
chain_id: 10
rpc: https://optimism.gateway.tenderly.co
contracts:
- name: USDC
details:
- network: ethereum
address: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"
start_block: 18600000
- network: arbitrum
address: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831"
start_block: 150000000
- network: optimism
address: "0x0b2c639c533813f4aa9d7837caf62653d097ff85"
start_block: 112000000
abi: ./abis/ERC20.json
tables:
- name: total_balances
cross_chain: true # Aggregate across ALL networks
columns:
- name: holder
- name: balance
default: "0"
events:
- event: Transfer
operations:
- type: upsert
where:
holder: $to
if: "$to != 0x0000000000000000000000000000000000000000"
set:
- column: balance
action: add
value: $value
- type: upsert
where:
holder: $from
if: "$from != 0x0000000000000000000000000000000000000000"
set:
- column: balance
action: subtract
value: $valueResult: One row per holder with their total balance across all chains.
DEX Pool State (Uniswap V2/V3 Style)
Track pool reserves, liquidity, and trading metrics:
tables:
# Pool state - reserves and liquidity
- name: pool_state
global: true # One row per pool per network
columns:
- name: reserve0
type: uint256
default: "0"
- name: reserve1
type: uint256
default: "0"
- name: total_supply
type: uint256
default: "0"
events:
- event: Sync
operations:
- type: upsert
set:
- column: reserve0
action: set
value: $reserve0
- column: reserve1
action: set
value: $reserve1
- event: Transfer # LP token mints/burns
operations:
# Mint (from zero address)
- type: upsert
if: "$from == 0x0000000000000000000000000000000000000000"
set:
- column: total_supply
action: add
value: $value
# Burn (to zero address)
- type: upsert
if: "$to == 0x0000000000000000000000000000000000000000"
set:
- column: total_supply
action: subtract
value: $value
# Trading metrics
- name: trading_metrics
global: true
columns:
- name: swap_count
type: uint64
default: "0"
- name: volume0
type: uint256
default: "0"
- name: volume1
type: uint256
default: "0"
events:
- event: Swap
operations:
- type: upsert
set:
- column: swap_count
action: increment
- column: volume0
action: add
value: $amount0In
- column: volume1
action: add
value: $amount1InResult: Complete pool state with reserves, supply, and volume metrics.
Governance Votes
Track votes per proposal with compound primary keys:
tables:
- name: votes
columns:
- name: proposal_id
- name: voter
- name: support # 0 = against, 1 = for, 2 = abstain
- name: voting_power
default: "0"
events:
- event: VoteCast
operations:
- type: upsert
where:
proposal_id: $proposalId
voter: $voter # Compound key: (proposal_id, voter)
set:
- column: support
action: set
value: $support
- column: voting_power
action: set
value: $votes
- name: proposal_totals
columns:
- name: proposal_id
- name: for_votes
default: "0"
- name: against_votes
default: "0"
- name: abstain_votes
default: "0"
events:
- event: VoteCast
operations:
- type: upsert
where:
proposal_id: $proposalId
if: "$support == 1"
set:
- column: for_votes
action: add
value: $votes
- type: upsert
where:
proposal_id: $proposalId
if: "$support == 0"
set:
- column: against_votes
action: add
value: $votes
- type: upsert
where:
proposal_id: $proposalId
if: "$support == 2"
set:
- column: abstain_votes
action: add
value: $votesResult: Two tables - individual votes by (proposal, voter) and aggregated totals per proposal.
Price High/Low Tracker
Track the highest and lowest prices using max and min actions:
tables:
- name: price_extremes
global: true
columns:
- name: highest_price
type: uint256
default: "0"
- name: lowest_price
type: uint256
default: "115792089237316195423570985008687907853269984665640564039457584007913129639935" # uint256 max
events:
- event: PriceUpdate
operations:
- type: upsert
set:
- column: highest_price
action: max
value: $price
- column: lowest_price
action: min
value: $priceChainlink Price Oracle (Cron)
Track ETH/USD price from Chainlink with periodic updates:
contracts:
- name: ChainlinkETHUSD
details:
- network: ethereum
address: "0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419"
abi: ./abis/ChainlinkAggregator.abi.json
tables:
# Global table - single row updated via upsert
- name: eth_price
global: true
columns:
- name: price
type: int256
- name: decimals
type: uint8
cron:
- interval: 5s
network: ethereum
operations:
- type: upsert
set:
- column: price
action: set
value: $call($contract, "latestAnswer()")
- column: decimals
action: set
value: $call($contract, "decimals()")
# History table - new row inserted each time
- name: eth_price_history
columns:
- name: price
type: int256
cron:
- interval: 5s
network: ethereum
operations:
- type: insert # Insert creates new rows - no where clause
set:
- column: price
action: set
value: $call($contract, "latestAnswer()")Registry with Delete (Whitelist/Blacklist)
Track active entries in a registry where items can be added and removed:
tables:
- name: verified_tokens
columns:
- name: token_address
- name: name
- name: symbol
events:
- event: TokenAdded
operations:
- type: upsert
where:
token_address: $token
set:
- column: name
action: set
value: $name
- column: symbol
action: set
value: $symbol
- event: TokenRemoved
operations:
- type: delete # Remove from registry entirely
where:
token_address: $tokenResult: Only currently verified tokens exist in the table. Removed tokens are deleted, not marked inactive.
Factory Indexing with Tables
Many protocols deploy contracts dynamically - Uniswap creates pools, Aave deploys markets, lending protocols spin up vaults. Factory indexing discovers these contracts automatically, and Tables can aggregate their data.
name: UniswapPoolMetrics
project_type: no-code
networks:
- name: ethereum
chain_id: 1
rpc: https://mainnet.gateway.tenderly.co
storage:
postgres:
enabled: true
contracts:
# The factory contract - discovers pool addresses
- name: UniswapV3Factory
details:
- network: ethereum
address: "0x1F98431c8aD98523631AE4a59f267346ea31F984"
start_block: 21000000
abi: ./abis/uniswap-v3-factory-abi.json
include_events:
- PoolCreated
# Factory-indexed pools with custom tables
- name: UniswapV3Pool
details:
- network: ethereum
start_block: 21000000
factory:
name: UniswapV3Factory
address: "0x1F98431c8aD98523631AE4a59f267346ea31F984"
abi: ./abis/uniswap-v3-factory-abi.json
event_name: PoolCreated
input_name: "pool" # Field containing the new pool address
abi: ./abis/uniswap-v3-pool-abi.json
tables:
# Aggregate metrics per pool
- name: pool_metrics
columns:
- name: pool_address
- name: swap_count
type: uint64
default: "0"
- name: total_volume_token0
type: int256
default: "0"
- name: total_volume_token1
type: int256
default: "0"
events:
- event: Swap
operations:
- type: upsert
where:
pool_address: $rindexer_contract_address # The pool that emitted the event
set:
- column: swap_count
action: increment
- column: total_volume_token0
action: add
value: $amount0
- column: total_volume_token1
action: add
value: $amount1- The factory contract (
UniswapV3Factory) is indexed first - When
PoolCreatedevents are found, rindexer automatically starts indexing those pool addresses Swapevents from all discovered pools update the custom tables$rindexer_contract_addressreferences the specific pool that emitted each event
Result: Aggregated metrics for every Uniswap V3 pool, discovered automatically.
Putting It All Together
A complete indexer with multiple tables:
name: DeFiDashboard
project_type: no-code
networks:
- name: ethereum
chain_id: 1
rpc: https://mainnet.gateway.tenderly.co
storage:
postgres:
enabled: true
contracts:
- name: Token
details:
- network: ethereum
address: "0x..."
start_block: 18600000
abi: ./abis/ERC20.json
tables:
# Table 1: Individual balances
- name: balances
columns:
- name: holder
- name: balance
default: "0"
events:
- event: Transfer
operations:
- type: upsert
where:
holder: $to
if: "$to != 0x0000000000000000000000000000000000000000"
set:
- column: balance
action: add
value: $value
- type: upsert
where:
holder: $from
if: "$from != 0x0000000000000000000000000000000000000000"
set:
- column: balance
action: subtract
value: $value
# Table 2: Global metrics
- name: metrics
global: true
columns:
- name: total_supply
type: uint256
default: "0"
- name: transfer_count
type: uint256
default: "0"
events:
- event: Transfer
operations:
# Track mints
- type: upsert
if: "$from == 0x0000000000000000000000000000000000000000"
set:
- column: total_supply
action: add
value: $value
# Track burns
- type: upsert
if: "$to == 0x0000000000000000000000000000000000000000"
set:
- column: total_supply
action: subtract
value: $value
# Count all transfers
- type: upsert
set:
- column: transfer_count
action: incrementQuerying Your Tables with GraphQL
Once you define custom tables, rindexer automatically generates a full GraphQL API to query them. No extra configuration needed - just enable GraphQL and your tables are instantly queryable.
Enable GraphQL
storage:
postgres:
enabled: true
graphql:
enabled: trueStart the GraphQL Server
rindexer start all # Starts both indexer and GraphQL serverGraphQL will be available at http://localhost:3001/graphql with a playground at http://localhost:3001/playground.
Example Queries
For a balances table, rindexer automatically generates queries like:
# Get all balances
query {
allBalances(first: 100, orderBy: BALANCE_DESC) {
nodes {
holder
balance
network
lastUpdatedBlock
lastUpdatedAt
}
pageInfo {
hasNextPage
endCursor
}
}
}
# Get a specific holder's balance
query {
allBalances(condition: { holder: "0x..." }) {
nodes {
holder
balance
network
}
}
}
# Filter by network
query {
allBalances(condition: { network: "ethereum" }, first: 50) {
nodes {
holder
balance
}
}
}What Gets Generated
For each custom table, you get:
| Query | Description |
|---|---|
all{TableName} | Query all rows with filtering, pagination, and ordering |
{tableName}ById | Get a specific row by primary key |
All your columns become queryable fields, including the auto-injected metadata columns
(network, lastUpdatedBlock, lastUpdatedAt, txHash, etc.).
Schema Migration
When you modify your custom tables in YAML (add columns, remove columns, change primary keys), rindexer
automatically detects and handles schema changes when you run rindexer start.
How It Works
On startup, rindexer compares your YAML table definitions against the actual database schema and:
| Change Type | Behavior |
|---|---|
| New column added | Auto-applies the change (adds column with default value) |
| Column removed | Prompts you to confirm deletion |
| Primary key changed | Prompts you to confirm (may fail if duplicates exist) |
| Column type changed | Warns you - requires manual migration |
Adding New Columns
New columns are automatically added with their default value from YAML:
tables:
- name: balances
columns:
- name: holder
- name: balance
default: "0"
- name: last_activity # NEW: will be auto-added
default: "0"When you run rindexer start, the column is added:
[rindexer] Schema changes detected:
✓ Adding column 'last_activity' (NUMERIC) DEFAULT 0 to table 'my_indexer_usdc.balances'
→ Column added successfullyExisting rows will have the default value (or NULL if no default specified).
Removing Columns
If you remove a column from your YAML, rindexer will prompt before deleting:
[rindexer] Schema changes detected:
? Column 'old_field' exists in database but not in YAML for table 'my_indexer_usdc.balances'
Delete this column? This will permanently remove data [y/N]:- Press
yto delete the column and its data - Press
n(or Enter) to keep the column - rindexer will ignore it during indexing
Changing Primary Keys
If you modify the where clause (which determines the primary key), rindexer will prompt:
[rindexer] Schema changes detected:
? Primary key change detected for table 'my_indexer_usdc.balances':
Current: (network, holder)
New: (network, holder, token_id)
Change primary key? This may fail if data has duplicates [y/N]:Type Changes (Manual Migration Required)
If you change a column's type, rindexer will warn you but cannot automatically migrate:
[rindexer] Schema changes detected:
! Column type change detected for 'amount' in table 'my_indexer_usdc.balances':
Current: bigint
New: numeric
Type changes require manual migration. Please backup your data and handle this manually.For type changes, you'll need to:
- Backup your data
- Drop and recreate the table, or
- Manually ALTER the column type with appropriate casting
CI/CD Automation with --yes
For automated deployments where interactive prompts aren't possible, use the --yes flag:
rindexer start all --yesWith --yes:
- New columns are still auto-added (same as normal)
- Column deletions are auto-confirmed
- Primary key changes are auto-confirmed
- Type change warnings are still shown (no auto-fix)
Best Practices
- Test schema changes locally first - Run
rindexer startlocally to see what changes will be applied - Backup before primary key changes - PK changes can fail and may require manual cleanup
- Avoid type changes when possible - If you need a different type, consider adding a new column instead
- Use defaults for new columns - Adding
default: "0"ensures existing rows have valid values
Tables vs Raw Event Logging
Custom Tables work independently from raw event logging (include_events):
| Config | What Happens |
|---|---|
tables only | Only custom tables are created and populated. No raw event tables. |
include_events only | Only raw event tables are created (traditional logging). |
Both tables and include_events | Both custom tables AND raw event tables are created. |
contracts:
- name: USDC
abi: ./abis/ERC20.json
details:
- network: ethereum
address: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"
start_block: 18600000
# No include_events = no raw event table
tables:
- name: balances
# ... table definitioncontracts:
- name: USDC
abi: ./abis/ERC20.json
details:
- network: ethereum
address: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"
start_block: 18600000
include_events:
- Transfer # Creates raw transfer table
tables:
- name: balances
# ... table definitionValidation Errors
rindexer validates your configuration at startup and provides clear error messages:
| Error | Meaning |
|---|---|
Event 'X' not found in ABI | The event name in tables.events doesn't exist in the contract ABI |
Field '$X' not found in event ABI | The event field you referenced (e.g., $foo) doesn't exist |
Column 'X' not found in table fields | The column name in set or where doesn't match any defined column |
Invalid condition expression | Syntax error in your if: condition |
All errors include the table name, event name, and contract name to help you locate the issue.
For Rust Project Users
Next Steps
- YAML Config Reference - Full configuration options
- Running Your Indexer - Start indexing
- GraphQL API - Query your data