Skip to main content

Balances & Holders Cubes

These cubes are query-only

Balances and Holders are derived views that answer "what is true now", so there is no underlying event to push. A subscription against either is a valid document and the socket stays open, but no message is ever delivered — on EVM or Tron.

For live balances, read the balance once and then keep it current from a stream that does fire: EVM.TransactionBalances or EVM.Transfers (and Tron.Transfers on Tron). See which cubes support subscriptions.

Two cubes answer balance questions, and picking the right one matters more than the fields you select:

CubeQuestion it answersShape
BalancesWhat does this address hold?Current balance per address, live — no snapshot date
HoldersWho holds this token?A token's holders ranked by balance, as a dated snapshot

Both supersede the older BalanceUpdates approach of summing deltas yourself. They read from aggregate-state tables (balances_by_address / balances_by_currency), so they return the current state directly instead of replaying every change.

Availability

Balances and Holders exist on EVM networks (EVM(network: …)) and Tron (Tron).

They do not exist on Solana. Solana balance changes are queried with Solana.BalanceUpdates and Solana.InstructionBalanceUpdates, which are the current APIs there — not deprecated.

Balances — what an address holds

Balances returns one row per address/currency pair with the current amount.

Balance fields: Address, Amount, AmountInUSD, UpdateCount, FirstChangeTime, LastChangeTime.

Its where filter accepts Balance.Address plus Currency and Block. Note there is no amount filter on Balances — see Holders if you need to filter or rank by balance size.

Token balance for one or more addresses

query TokenBalances($addresses: [String!], $token: String!) {
EVM(network: eth, dataset: combined) {
Balances(
where: {
Balance: { Address: { in: $addresses } }
Currency: { SmartContract: { is: $token } }
}
orderBy: { descending: Balance_Amount }
) {
Balance {
Address
Amount
AmountInUSD
UpdateCount
LastChangeTime
}
Currency {
Symbol
Name
SmartContract
}
}
}
}
{
"addresses": [
"0x28C6c06298d514Db089934071355E5743bf21d60",
"0x21a31Ee1afC51d94C2eFcCAa2092aD1028285549"
],
"token": "0xdac17f958d2ee523a2206206994597c13d831ec7"
}

To exclude zero balances, apply the filter on the field rather than in where: Amount(selectWhere: { gt: "0" }).

Native balance

Filter on Currency: { Native: true } to get the chain's native asset instead of a token:

{
EVM(network: eth, dataset: combined) {
Balances(
where: {
Balance: { Address: { is: "0x28C6c06298d514Db089934071355E5743bf21d60" } }
Currency: { Native: true }
}
) {
Balance {
Address
Amount
AmountInUSD
}
Currency {
Symbol
Native
}
}
}
}

The same query works on Tron by swapping the selector for Tron and using a Tron address.

Classifying an address from its balance metadata

UpdateCount with LastChangeTime separates wallet types without any labelling data:

  • Very high UpdateCount, LastChangeTime seconds ago — exchange hot wallet or payment processor; the balance churns continuously.
  • Single-digit UpdateCount on a large balance — cold storage, treasury or custody. Funded once, rarely touched.
FirstChangeTime is not the address's first-ever activity

FirstChangeTime reflects the earliest change within the balances table's retention window, not the first time the address ever moved funds. For true first-activity, query Transfers with an ascending time order instead.

Holders — who holds a token

Holders lists a token's holders, ranked. It takes a date argument for the snapshot day, and unlike Balances it does accept a balance filter — which is what makes large tokens tractable.

query TokenHolders($token: String!, $floor: String!, $date: String!) {
EVM(network: eth) {
Holders(
limit: { count: 100 }
orderBy: { descending: Balance_Amount }
date: $date
where: {
Currency: { SmartContract: { is: $token } }
Balance: { Amount: { ge: $floor } }
}
) {
Holder {
Address
}
Balance {
Amount
}
Currency {
Symbol
Name
}
}
}
}
{
"token": "0x514910771AF9Ca656af840dff83E8264EcF986CA",
"floor": "1000000",
"date": "2026-07-29"
}
Set a balance floor on large tokens

An unbounded top-N ranking across every holder of a very large token times out server-side — this is a dataset-size limit, not a syntax error. Ethereum USDT times out even with a 50,000,000 floor; Tron USDT behaves the same way.

The fix is a Balance: { Amount: { ge: … } } floor high enough to shrink the working set. For a complete holder distribution rather than the top of it, use Bitquery Cloud exports or Kafka streams rather than a synchronous GraphQL query.

Choosing a dataset

DatasetWhen to use
combinedLatest balances. Queries realtime and archive and merges the results.
archiveHistorical snapshots, and balances for addresses that have not been active recently.
realtimeRecent state only. Some aggregates are unavailable here — if you see "no table can query … consider use archive dataset", switch to combined or archive.

Migrating from BalanceUpdates

If you previously summed balance deltas, the translation is mechanical:

Old (BalanceUpdates)New
BalanceUpdates(where: {BalanceUpdate: {Address: {is: …}}})Balances(where: {Balance: {Address: {is: …}}})
balance: sum(of: BalanceUpdate_Amount, selectWhere: {gt: "0"})Balance { Amount(selectWhere: {gt: "0"}) }
BalanceUpdate { Address }Balance { Address }
BalanceUpdate { AmountInUSD }Balance { AmountInUSD }
Top holders via sum + orderBy: {descendingByField: "balance"}Holders(date: …, orderBy: {descending: Balance_Amount})

The important conceptual change: you no longer aggregate. Balances already holds the summed state, so a sum(of: …) over balance updates becomes a plain field read.

Deadline: 10 August 2026

EVM.BalanceUpdates, EVM.TokenHolders and Tron.BalanceUpdates sunset on 10 August 2026. They still return live data today, so nothing has broken yet — but anything still calling them stops working on that date.

EVM.TokenHolders has already been withdrawn ahead of the others and now returns no table can query TokenHolder.

Per-change history becomes daily aggregates

This is the deliberate design change, not a missing feature. BalanceUpdates gave you one row per change. Balances gives you one row per address per day, exposed as Block.Date.

For most balance questions the daily grain is what you actually wanted, and it is far cheaper: a 30-day balance history is one query returning 30 rows, rather than a scan over every change in that period which you then aggregate yourself.

query DailyBalanceHistory {
EVM(network: eth, dataset: archive) {
Balances(
where: { Balance: { Address: { is: "0x28c6c06298d514db089934071355e5743bf21d60" } } }
orderBy: { descending: Block_Date }
limit: { count: 30 }
) {
Block {
Date
}
Balance {
Amount
AmountInUSD
}
Currency {
Symbol
}
}
}
}

Always order by Block_Date. Without it you get an arbitrary day and the query still succeeds, which makes the mistake silent.

You needUse
Current balance per addressBalances (latest row)
Balance on a past date, or a daily seriesBalances with Block.Date — or Holders(date: …), which agrees exactly
A token's holders, rankedHolders
When a position first or last movedBalancesFirstChangeTime, LastChangeTime, UpdateCount
The individual transfers behind a changeTransfers

What genuinely does not carry over is sub-daily change attribution. BalanceUpdates exposed Type (transfer, fee, block_reward, …) per change; the daily aggregate has no equivalent. If you need to know why a balance moved rather than what it became, reconstruct it from Transfers plus transaction context.