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.
Balances are latest-wins: the current holding of a
(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
ClickHouse uses the collapsing log-table pattern (see the recipes overview) so chain reorganizations self-correct, a reorg negates the rolled-back block’s observations (
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.Run it
Everything runs in your infrastructure: the sink writes into a database you own and you query it with plain SQL, no API and no per-call limits. Prerequisites- Docker and Docker Compose.
- A Data Feeds key, a
cm_live_…key withcontinuum:readscope (this is not your Web3 API key).
Sign up for a Data Feeds key
Log in to the Moralis admin panel to create your account and generate a
cm_live_… key.tokenHolders in the Moralis Admin Panel, pick your chain and destination database, then run it with Docker. The quickstart walks through it end to end. Set the backfill window (MORALIS_HISTORICAL_FROM_BLOCK) as noted below.
Backfill from the token’s first block for a complete holder set. A holder list is current state, the latest transfer-derived balance per
(token, wallet), so to return every holder you need the token’s full transfer history. The good news: that’s history since the token launched, not chain genesis, so set MORALIS_HISTORICAL_FROM_BLOCK to around the token’s deployment block. A recent-only window would miss holders who haven’t moved the token lately. See History & backfill.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:
Other ways to consume this data
The sink is the way to get this pre-aggregated table (token_holders), recommended for a holder list you own. The same underlying data is also available as the raw decoded feed, if you’d rather maintain holder state yourself:
- Your existing message clients: stream it with Kafka / AMQP / SQS.
- REST / Arrow Flight: pull the decoded block stream directly.
- Your warehouse: query it via Iceberg from Snowflake, BigQuery, Spark, or DuckDB.
tokenTransfers, which carries absolute post-transfer balances) to compute the holder set yourself, not this pre-aggregated table. All of them read the one data lake into your infrastructure. See What are Data Feeds?.
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.
Migrating from the REST API
This recipe replaces the token-holder family of REST endpoints, the EVM Web3 API holder list and summary, and the deprecated Solana Token API holder endpoints. Data Feeds are not 1:1 replicas of those responses: you land the holder set in your own database and reconstruct each response shape with SQL. The subsections below cover each REST endpoint, what replaces it, the field mapping (exact = straight from the chain, calculated = derived from real DEX trades, very close, add yourself = off-chain signal or a definition you set), and the gotchas specific to that endpoint.GET /erc20/:address/owners
The per-holder list, every owner, biggest first, with balance and USD value. The most direct migration on this page: this recipe’stoken_holders table is the holder list, and the ranking becomes your ORDER BY.
For balance_formatted, percentage_relative_to_total_supply, and the envelope totalSupply, add Token Metadata (token_metadata) to the same database, the raw list needs only this recipe, the formatted fields need the join.
The reconstruction query (Postgres, adapt to your destination):
- Ordering and filtering are yours: the endpoint hard-coded biggest-first and non-zero-only; with your own table you rank by
balanceorusd_value, set any minimum, and page with plain SQL, no per-call limit. - The stored
usd_valueassumes 18 decimals, recompute asbalance / 10^decimals × usd_pricewith real decimals fromtoken_metadatafor non-18-decimal tokens (see fidelity gaps). - USD values come from real DEX trades, very close to the old endpoint, compare with a tolerance; a thinly-traded token may value at 0.
- Backfill from the token’s deployment block (see Run it) or the list silently misses holders who haven’t moved the token lately.
GET /erc20/:tokenAddress/holders
The aggregate holder summary,totalHolders, supply concentration, size tiers, acquisition split, and trend windows, not the per-holder list. There is no single replacement table: you compute the summary yourself over this recipe’s holder set, which turns the black-box buckets into definitions you control.
The headline numbers in one pass (Postgres, tier thresholds are illustrative, set your own):
- The foundation is exact, the analytics are yours: holder count and concentration are deterministic SQL; the tier boundaries and acquisition rules are conventions you define, once fixed, the counts are exact.
- Materialize for large tokens, USDC has millions of holders; compute the summary on a schedule into its own table, not per request.
- Full-history backfill matters even more here: start late and you undercount holders and skew every aggregate, including the 30-day trend and the first-acquisition split.
- Decimals matter for USD tiering, recompute
usd_valuewith realdecimalsbefore bucketing non-18-decimal tokens.
Solana: GET /token/:network/holders/:address
The Solana holder-metrics summary, holder count and size distribution for an SPL token. Replaced by this recipe withchain set to Solana: tokenHolders maintains one current balance per (mint, owner) and you aggregate the metrics in SQL. The field mapping matches the /erc20/:tokenAddress/holders table above, totalHolders is exact, holderDistribution and holdersByAcquisition are definitions you set (the acquisition split again needs Token Transfers), and holderChange is calculated from a count series you maintain.
Total plus distribution tiers (Postgres, swap in your own thresholds, by usd_value or raw balance):
- Don’t lowercase Solana addresses: EVM addresses in this recipe are stored lowercased, but base58 mints and owners are case-sensitive, match them as-is.
- SPL account model: the decoded feed resolves transfers to owner wallets, so
wallet_addressis the owner; one owner can hold across multiple token accounts, sum them for a per-owner total. - Backfill from around the mint’s deployment or the count silently undershoots.
Solana: GET /token/:network/:address/top-holders
The ranked per-holder list for an SPL token, the Solana twin of/erc20/:address/owners, and the same direct migration: this recipe on Solana plus ORDER BY balance DESC. The field mapping matches the /erc20/:address/owners table above with camelCase names (ownerAddress, balanceFormatted, usdValue, percentageRelativeToTotalSupply); isContract (is the owner a program-owned account) is add yourself from your own account-type source. Formatted balance and supply percentage again need Token Metadata.
- Sunsetting July 31, 2026 along with the holder-metrics endpoint above, plan the cutover now.
- SPL decimals vary per token, format against real
decimalsfromtoken_metadata, never a fixed 18; the stored best-effortusd_valueassumes 18. - Same base58 case-sensitivity and owner-vs-token-account notes as above.
- USD value comes from real trades, compare with a tolerance; a thinly-traded token may value at 0.
Related
Token Transfers
The per-token movement ledger this holder set is derived from.
Token Analytics
The use case holder distribution and concentration power.

