Skip to content

Custom Tables

Key Features

  • Upsert, Insert, Update, Delete - Full CRUD operations on your custom tables
  • Computed Values - Arithmetic expressions like $amount * 2, $price / $quantity, or 10 ^ $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) with iterate
  • 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: 5m or 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 3

To 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: 25

One row per address. Instant balance lookups. No aggregation needed.


Quick Start

Here's a complete example that tracks ERC20 token balances:

rindexer.yaml
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: $value

Result: 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_balances

global

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 time

cross_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"
Why is this opt-in?

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.

PropertyRequiredDescription
nameYesColumn name
typeNoData type (auto-inferred from event ABI if not specified)
defaultNoDefault value for new rows
nullableNoWhether column allows NULL values (default: false = NOT NULL)

Type Inference Rules

Column types are automatically inferred in these cases - no type: needed:

Value SourceExampleInferred Type
Event field$from, $value, $toFrom ABI (e.g., address, uint256)
Nested event field$data.amountFrom ABI
Transaction metadata$rindexer_block_numberuint64
Transaction metadata$rindexer_tx_hash, $rindexer_block_hashstring
Transaction metadata$rindexer_contract_addressaddress
Transaction metadata$rindexer_block_timestamptimestamp (requires timestamp: true)
Default value "0"default: "0"uint256
Default value booleandefault: "true"bool
Default value addressdefault: "0x000..."address

You must specify type: in these cases:

Value SourceExampleWhy
View calls$call($addr, "balanceOf(address)", $holder)Return type unknown
Computed/arithmetic$amount * 2, $a + $b, 10 ^ $decimalsResult 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 referenceColumn not used in set or whereNothing to infer from
Examples:
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 literal

Supported Types

TypeDescriptionPostgreSQLClickHouse
addressEthereum addressCHAR(42)FixedString(42)
stringTextTEXTString
boolBooleanBOOLEANBool
uint8 - uint64Unsigned integersBIGINTUInt64
uint128 - uint256Large unsigned integersNUMERICUInt256
int8 - int64Signed integersBIGINTInt64
int128 - int256Large signed integersNUMERICInt256
bytesDynamic bytesBYTEAString
bytes32Fixed 32 bytesBYTEAFixedString(66)
timestampDate/timeTIMESTAMPTZDateTime
address[]Array of addressesTEXT[]Array(String)
uint256[]Array of uint256TEXT[]Array(String)
bytes32[]Array of bytes32TEXT[]Array(String)

Array Types

Arrays from event parameters are supported and stored as database arrays:

columns:
  - name: participants
    type: address[]
  - name: amounts
    type: uint256[]
What works:
  • 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)
Limitations:
  • Cannot use arrays in where clauses - Arrays can't be part of primary keys (use iterate to 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

TypeDescriptionUse Case
upsertInsert new row or update existingMost common - balances, ownership
insertInsert a new row (no conflict handling)Time-series data, price history, logs
updateUpdate existing row only (no insert)Modify existing records
deleteRemove the rowClean 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: $value

Set Actions

ActionDescriptionExample Result
setReplace valuebalance = 100
addAdd to existingbalance = balance + 50
subtractSubtract from existingbalance = balance - 50
maxKeep the larger valuehigh = max(high, 150)
minKeep the smaller valuelow = min(low, 50)
incrementAdd 1count = count + 1
decrementSubtract 1count = 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 ID

Tuples 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 fields
Example: Event with Tuple/Struct Parameter

For 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.amount

Array 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' array

This 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 swap

Array 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 value
How it works:
  1. iterate takes a list of array bindings in the format "$arrayField as alias"
  2. All arrays must have the same length (they're processed in parallel)
  3. For each index, the operations are executed with the aliased values bound
  4. Use the aliases ($token_id, $amount) in where, if, and set clauses

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 block

View 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 $field reference from the event
  • function_signature: The function signature in Solidity format (e.g., "balanceOf(address)")
  • args: Arguments to pass to the function (can be $field references 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
ApproachSyntaxWhen to Use
Position-based$call(...)[0]Quick, no setup needed
Named fields$call(... returns (type name, ...)).fieldNameSelf-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 blockTimestampLast

Pros: 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)").reserve1

Pros: 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       # Address

Null Values

Set a column to SQL NULL using $null:

value: $null                                            # Explicit SQL NULL

Conditional 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 >= 1M

Syntax: $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
Supported operators in conditions:
OperatorMeaning
==Equal
!=Not equal
>Greater than
>=Greater or equal
<Less than
<=Less or equal
&&Logical AND
||Logical OR
Examples:
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 power

Supported 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()")
How it works:
  1. All $call() expressions in the arithmetic are resolved first (fetched from the blockchain)
  2. The returned values replace the $call() placeholders
  3. 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()"))
Formula breakdown:
  • $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 text

Constants

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)
How it works:
  • When the event is processed on Ethereum, $constant(oracle) resolves to 0x54586bE62E3c3580375aE3723C145253060Ca0C2
  • 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

OperatorMeaningExample
==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

OperatorMeaningExample
&&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

SyntaxMeaningWhen to Use
$valueIncoming event valueCompare event data
@balanceCurrent database valueCompare with existing state
Example: Only update if the new value exceeds the current balance
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

FormatExampleDescription
Simple interval5s, 30s, 5m, 1h, 1dFixed time intervals
Cron expression"*/5 * * * *"Standard cron syntax (every 5 minutes)
Simple Intervals:
  • 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:

VariableDescription
$call(...)View function calls (same syntax as events)
$contractContract address from contract details
$rindexer_block_numberLatest block number at execution time
$rindexer_timestampCurrent timestamp
LiteralsString/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

FieldRequiredDescription
start_blockYes (for historical)Block number to begin historical sync from
end_blockNoBlock number to stop at. If omitted, syncs to latest then continues live
block_intervalNoHow many blocks between each execution. Default: 1 (every block)

Behavior

  1. Historical sync first: If start_block is specified, rindexer replays the cron operations from start_block forward, executing at each block_interval step.

  2. Database state tracking: Progress is saved to the database, so if you restart, it resumes from where it left off (just like event indexing).

  3. After historical sync completes:

    • If end_block is specified → The cron stops completely (no live mode)
    • If end_block is omitted → Switches to live mode using the interval or schedule

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:

  1. Insert a price snapshot at blocks 24184625, 24184725, 24184825, ... up to the latest block
  2. 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 TypeExpected SpeedBehavior
Paid/Enterprise~300 blocks/secScales up to 100 concurrent requests, 1000-block batches
Free Public~20 blocks/secAutomatically 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:

ColumnTypeDescription
networkVARCHARNetwork name (omitted if cross_chain: true)
rindexer_sequence_idNUMERIC NOT NULLUnique ID for deterministic ordering
rindexer_block_numberBIGINT NOT NULLBlock number of the event
rindexer_block_timestampTIMESTAMPTZ NOT NULLBlock timestamp of the event (only if timestamp: true)
rindexer_tx_hashCHAR(66) NOT NULLTransaction hash of the event
rindexer_block_hashCHAR(66) NOT NULLBlock hash of the event
rindexer_contract_addressCHAR(42) NOT NULLContract 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: $to

Result: 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: $value

Result: 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: $amount

Result: 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: $value

Result: 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: $value

Result: 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: $amount1In

Result: 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: $votes

Result: 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: $price

Chainlink 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: $token

Result: 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
How it works:
  1. The factory contract (UniswapV3Factory) is indexed first
  2. When PoolCreated events are found, rindexer automatically starts indexing those pool addresses
  3. Swap events from all discovered pools update the custom tables
  4. $rindexer_contract_address references 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: increment

Querying 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

rindexer.yaml
storage:
  postgres:
    enabled: true
graphql:
  enabled: true

Start the GraphQL Server

rindexer start all  # Starts both indexer and GraphQL server

GraphQL 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:

QueryDescription
all{TableName}Query all rows with filtering, pagination, and ordering
{tableName}ByIdGet 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 TypeBehavior
New column addedAuto-applies the change (adds column with default value)
Column removedPrompts you to confirm deletion
Primary key changedPrompts you to confirm (may fail if duplicates exist)
Column type changedWarns 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 successfully

Existing 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 y to 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:

  1. Backup your data
  2. Drop and recreate the table, or
  3. 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 --yes

With --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

  1. Test schema changes locally first - Run rindexer start locally to see what changes will be applied
  2. Backup before primary key changes - PK changes can fail and may require manual cleanup
  3. Avoid type changes when possible - If you need a different type, consider adding a new column instead
  4. 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):

ConfigWhat Happens
tables onlyOnly custom tables are created and populated. No raw event tables.
include_events onlyOnly raw event tables are created (traditional logging).
Both tables and include_eventsBoth custom tables AND raw event tables are created.
Example: Tables only (no raw event storage)
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 definition
Example: Both tables AND raw events
contracts:
  - 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 definition

Validation Errors

rindexer validates your configuration at startup and provides clear error messages:

ErrorMeaning
Event 'X' not found in ABIThe event name in tables.events doesn't exist in the contract ABI
Field '$X' not found in event ABIThe event field you referenced (e.g., $foo) doesn't exist
Column 'X' not found in table fieldsThe column name in set or where doesn't match any defined column
Invalid condition expressionSyntax 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