Question it answers
“Who holds token 0x… — with their current balance and USD value? Give me the full holder list, biggest first.”Mirrors Moralis
GET /erc20/{address}/owners. Storing the holder set pre-aggregated, keyed by token, in your own database is the value — no per-request rebuild from raw transfers.
What you get
Token transfers carry the absolute post-transfer balance of both sides of every transfer (fromPostBalance / toPostBalance). Each transfer becomes two per-wallet balance observations, and the latest observation per (token, wallet) is the current holding — no running-sum reconstruction. Each observation is USD-enriched in-block from the same block’s price updates.
| Column | Description |
|---|---|
token_address | The token contract being held |
wallet_address | The holder |
balance | Absolute balance after the latest transfer, raw uint256 as text |
usd_price | In-block USD spot price per whole token (newest-wins from the block’s price updates; 0 if no same-block update) |
usd_value | Best-effort holding value — balance / 10^18 × usd_price (see fidelity gaps) |
block_number, log_index | Recency tuple — the latest pair wins as the current holding |
leg | from / to — which side of the transfer produced this observation |
(token, wallet) is simply the observation with the highest (block_number, log_index).
Source
The transform reads one per-block array and unpivots it:tokenTransfers
Each transfer yields (from_address, from_post_balance) and (to_address, to_post_balance). The EVM zero address (mint/burn counterparty) and any side without a resolved post-balance are skipped. USD spot prices are folded in inline from the same block’s tokenPriceUpdates (reversed so the chronologically-last update wins per token) — no separate price join at read time.
This recipe is Token Balances by Token plus the in-block USD valuation leg.
Destination
| Destination | Table | Read pattern |
|---|---|---|
| ClickHouse (first-class) | fact_token_holders | Prefix scan on (chain_id, token_address, wallet_address, …); current holding via argMax over FINAL |
| Postgres | token_holders (materialized view over token_holder_observations) | Partial index (token_address, wallet_address) WHERE balance > 0 |
| MySQL | token_holders (trigger-maintained from token_holder_observations) | PK (token_address, wallet_address) + DELETE … WHERE balance = 0 cleanup |
sign = -1) and re-emits the corrected ones, and FINAL collapses the pair before argMax so the aggregate sees only canonical state. The fact table’s sort key is token-first, so a token’s holder set is a contiguous range read.
Postgres and MySQL keep an append-only observations table and derive the current-holder projection: Postgres as a DISTINCT ON (token, wallet) … ORDER BY block_number DESC, log_index DESC materialized view (refresh on a schedule), MySQL via an AFTER INSERT latest-wins upsert trigger with a periodic DELETE … WHERE balance = 0 cleanup.
Full schema
Below is the complete read table this recipe produces. It’s a starting point — keep the columns you need and drop the rest (see Schema & flexibility). Rawuint256 balances are stored as text (they exceed numeric precision); usd_price is a wide decimal so a low-decimals token × price never overflows.
ClickHouse — fact_token_holders
ClickHouse — fact_token_holders
sign column drives reorg collapsing — read with FINAL (then argMax) or a sign-aware aggregate, never a bare WHERE sign = 1. leg keeps the two unpivoted rows of one transfer distinct; the +1/-1 reorg pair for one leg shares a key and collapses. A single-node setup can use CollapsingMergeTree(sign) without the replication path.Postgres — token_holders
Postgres — token_holders
DECIMAL(76,0) / DECIMAL(38,18), and replaces the materialized view with a trigger-maintained token_holders table. position is the block-level cursor used during backfill. balance is typed NUMERIC(76, 0) so the raw uint256 survives without overflow.Example reads
All current non-zero holders of a token with balance and best-effortusd_value — FINAL collapses reorg ±1 pairs before argMax (ClickHouse). The example assumes 18 decimals; substitute the token’s real decimals to be exact:
Modes
Shipped defaults: ClickHousehybrid (backfill → realtime), Postgres / MySQL historical (one-shot backfill). For live/reorg-safe ingestion, use ClickHouse — see the overview.
The Postgres materialized view and the MySQL trigger state are not reorg-aware on their own — under realtime you would have to re-derive or refresh them. Run realtime/hybrid on ClickHouse, where the collapsing log table corrects reorgs per-block via
sign automatically; the Postgres/MySQL configs target historical backfill.Multichain
The recipe is chain-parametrized via thechain setting — point it at any supported EVM chain or Solana. On Solana, the event identity already includes (from, to, token, amount) so each observation stays row-unique despite Solana’s repeated logIndex within an instruction; the holder set it produces is identical in shape.
Fidelity gaps
The Moralis/erc20/{address}/owners response has fields that aren’t derivable from normalized per-block data and are therefore omitted or approximated:
balance_formatted— needs the token’sdecimals, which is contract metadata not present intokenTransfers. Only the rawbalance(uint256 string) is emitted; divide by10^decimalsoff-stream to format. Source decimals from a Token Metadata sync.usd_value— emitted best-effort assuming 18 decimals (balance / 1e18 × usd_price). The faithful per-token USD spotusd_price(same-block, newest-wins) is stored alongside, so a consumer that knows the real decimals can recompute exactly:usd_value = balance / 10^decimals × usd_price. Tokens with no same-block price update getusd_price = 0(→usd_value = 0) for that observation.percentage_relative_to_total_supplyandtotal_supply— there’s no clean circulating/total-supply figure in the block stream (it would require summing all mints − burns since genesis or a contracttotalSupply()read). Omitted.owner_address_label,is_contract,entity,entity_logo— off-chain enrichment / address-classification metadata, not on-chain block data. Omitted.
token_address → holder wallet_address + current balance — is fully covered, so these gaps don’t block the recipe.
Related
Token Transfers
The per-token movement ledger this holder set is derived from.
Token Analytics
The use case holder distribution and concentration power.

