Skip to main content

Question it answers

“Give me every NFT wallet 0x… currently holds. Each row is a (collection, token_id) it owns, with the ERC-721/ERC-1155 contract type and current balance.”
Mirrors Moralis GET /{address}/nft. It’s the NFT-keyed sibling of Token Balances by Wallet: the same observation/latest-wins pattern, but sourced from NFT transfers and keyed on (wallet, token_address, token_id) instead of (wallet, token_address).

What you get

The recipe lands NFT holding observations, then the current holding is the latest non-zero observation per (wallet, token_address, token_id). Each NFT transfer carries the absolute post-transfer holdings of each side (from/to), so the recipe never has to sum a running balance; the latest observation is the balance. The EVM zero address (0x0000…0000) is a mint/burn counterparty, not a holder, so it’s excluded. Observations with an empty post-balance string are filtered so the balance column never receives ''.

Source

The transform reads one per-block array and unpivots each transfer into two per-wallet observations: nftTokenTransfers Each transfer becomes (from_address, from_post_balance) and (to_address, to_post_balance). from_post_balance / to_post_balance are the absolute post-transfer holdings of that wallet for the given (token_address, token_id), so the latest observation with a non-zero balance is a current holding.

Destination

ClickHouse uses the collapsing log-table pattern (see the recipes overview) so chain reorganizations self-correct. The fact table’s sort key is wallet-first, so a wallet’s full holdings set is a contiguous range read; argMax over (block_number, log_index) resolves the current balance per id. On Postgres/MySQL the current-holding table is derived by keeping the latest observation per (wallet, token_address, token_id).

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). token_id is a uint256 identifier stored as text (never numeric, ERC-1155/721 ids exceed any practical numeric precision), and balance is the absolute post-transfer holding.
The sign column drives reorg collapsing, read with FINAL then argMax, or a sign-aware aggregate, never a bare WHERE sign = 1. A single-node setup can use CollapsingMergeTree(sign) without the replication path.
Refresh the current-holding view on a schedule with REFRESH MATERIALIZED VIEW CONCURRENTLY wallet_nfts;. MySQL is the same shape with a trigger-maintained wallet_nfts table and a DELETE … WHERE balance = 0 cleanup. position is the block-level cursor used during backfill.

Example reads

Every NFT a wallet currently holds, latest non-zero post-balance per id (ClickHouse):
The same holdings read on Postgres (after refreshing the view):

Modes

Shipped defaults: ClickHouse hybrid (backfill → realtime), Postgres / MySQL historical (one-shot backfill). For live/reorg-safe ingestion, use ClickHouse, see the overview.
position is block-level, so realtime/hybrid on Postgres/MySQL is constrained by their single-column UNIQUE requirement. Run realtime/hybrid on ClickHouse; the Postgres/MySQL configs target historical backfill.

Multichain

The recipe is chain-parametrized via the chain setting, point it at any supported EVM chain or Solana. NFTs on Solana are emitted on nftTokenTransfers too; the vendor_event_id identity already includes (from, to, token, tokenId, amount) so each row stays unique under Solana’s repeated log indices. The holdings table it produces is identical in shape.

Fidelity gaps

GET /{address}/nft returns collection and metadata fields that have no source in this per-block stream, so they are intentionally not populated:
  • name, symbol, NFT contract name/symbol. Enrich downstream from a contract-metadata table if you need them.
  • token_uri, metadata, normalized_metadata, per-token metadata fetched from the token URI by a separate indexer; not in block data.
  • owner_of: the API echoes the queried wallet; here it is the wallet_address holding key.
  • block_number_minted, minter_address, possible_spam, verified_collection, collection_logo, floor_price*, rarity*, enrichment from separate indexers / pricing services, with no per-block source.
Core holding fields, token_address, token_id, contract_type, balance, owner (via wallet_address), and block coordinates, are fully populated.

Migrating from the REST API

This recipe is the landing point for two wallet-NFT REST endpoints. In both cases the onchain holding is exact; the metadata, spam, and pricing layers the API bundled in are separate recipes or your own sources.

GET /:address/nft

This recipe replaces it directly: one current row per (wallet, token_address, token_id) with a non-zero balance is exactly the holdings list the endpoint returned.
Not 1:1, the full response spans two recipes. This recipe supplies the holdings; join NFT Collection Metadata (nft_collection_metadata, keyed by token_address) for name/symbol. Token-level metadata (token_uri, metadata, media) is resolved off-chain from each token’s URI and is not in any per-block feed, you fetch it yourself or layer in a metadata source.
The endpoint response, as one query (Postgres table names; keyset pagination on (token_address, token_id) replaces the old cursor):
Gotchas:
  • Backfill full history. Holdings are current state, a wallet can still hold an NFT it last moved years ago, so run historical/hybrid from an early block. Only tracking specific collections? Filter to those token_addresses and start from each collection’s first block. See History & backfill.
  • No fixed spam filter. The old exclude_spam was off-chain curation; it becomes your own WHERE against your own lists.
  • ERC-1155 amount is a real count, not always 1, the balance column carries it.
  • Empty name/symbol is normal for many ERC-1155 (and some ERC-721) collections; it isn’t an error.

GET /:address/nft/collections

There is no separate collections dataset, this endpoint is a GROUP BY rollup of this recipe’s holdings table. If you’ve built the per-NFT query above, the collection list is one more query over the same tables.
Not 1:1, combine this recipe (holdings to group) with NFT Collection Metadata for name/symbol, and optionally NFT Trades if you want last_sale from realized onchain sales.
The rollup:
Gotchas:
  • ERC-1155 count. count(*) counts distinct token_ids (matches the old endpoint); swap in sum(balance) to count units instead, a one-word change.
  • Same full-history requirement as the per-NFT endpoint: start the backfill late and the rollup silently misses collections.
  • last_sale USD is calculated from real trades plus a price feed, compare with a small tolerance, not an identical figure.
  • Floor price, logo, banner are off-chain: layer them in from your own sources keyed by token_address, or drop them.

Token Balances by Wallet

The ERC-20 sibling, same latest-wins pattern, keyed by (wallet, token).

NFT Owners by Contract

The inverse view, all current holders of a collection.

Portfolio Tracking

Wallet holdings across tokens and NFTs in one owned dataset.

NFT Marketplace

Per-wallet NFT inventory behind a marketplace.