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.
| Column | Description |
|---|---|
wallet_address | The holder (the from or to side of a transfer) |
token_address | The NFT contract (collection) |
token_id | The NFT identifier — a uint256, stored as text (it exceeds numeric precision) |
contract_type | ERC721 or ERC1155 discriminator (from the transfer’s token type) |
balance | Absolute post-transfer holding of this token_id for this wallet (1/0 for ERC-721, a count for ERC-1155) |
block_number, log_index | Ordering tuple — the latest pair wins as the current holding |
event_ts / block_timestamp | Block time |
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
| Destination | Table | Read pattern |
|---|---|---|
| ClickHouse (first-class) | fact_wallet_nfts | Prefix scan on (chain_id, wallet_address, token_address, token_id, …); current holding via argMax over FINAL |
| Postgres | wallet_nfts (materialized view over wallet_nft_observations) | Partial index (wallet_address, token_address, token_id) WHERE balance > 0 |
| MySQL | wallet_nfts | PK (wallet_address, token_address, token_id) with zeroed positions cleaned up |
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.
ClickHouse — fact_wallet_nfts
ClickHouse — fact_wallet_nfts
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.Postgres — wallet_nfts
Postgres — wallet_nfts
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):Modes
Shipped defaults: ClickHousehybrid (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 thechain 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 thewallet_addressholding 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.
token_address, token_id, contract_type, balance, owner (via wallet_address), and block coordinates — are fully populated.
Related
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.

