Question it answers
“Give me every current owner of NFT collection 0x… — eachThis is the by-collection sibling of NFTs by Wallet — same ingest, sorted the other way. Each NFT transfer carries the absolute post-transfer holdings of both wallets, so the latest observation pertoken_id, the wallet that holds it, and how many (ERC-1155 editions).” Mirrors MoralisGET /nft/{address}/owners.
(token_address, token_id, wallet) is the current owner. Storing the resolved owner set in your own database — keyed by collection — is the value.
What you get
Each NFT transfer becomes two per-wallet holding observations (leg = from | to), where amount is the absolute count of that token_id held by the wallet after the transfer. The latest observation per (token_address, token_id, wallet_address) wins; keep amount > 0 for the current owner set.
| Column | Description |
|---|---|
token_address | The NFT collection (contract) |
token_id | The specific NFT — uint256, stored as text (256-bit ids overflow numeric types) |
wallet_address | The holder; the EVM zero address is excluded (mint/burn counterparty) |
amount | Absolute post-transfer holding of this token_id — 0/1 for ERC-721, an arbitrary count for ERC-1155 editions |
contract_type | ERC-721 / ERC-1155 discriminator |
leg | from or to — which side of the transfer produced this observation |
block_number, log_index | Recency tuple — latest wins per holding key |
event_ts / block_timestamp | Block time |
Source
The transform reads one per-block array —nftTokenTransfers — and unpivots each event into two observations using its fromPostBalance / toPostBalance fields (the from- and to-wallet’s absolute count of that token_id after the transfer). No price or metadata join is involved.
Destination
| Destination | Table | By-collection access |
|---|---|---|
| ClickHouse (first-class) | fact_nft_owners_by_contract | Prefix scan on (chain_id, token_address, token_id, wallet_address, …); current owner via argMax over FINAL |
| Postgres | nft_owner_observations → nft_owners (materialized view) | Partial index (token_address, token_id, wallet_address) WHERE amount > 0 |
| MySQL | nft_owner_observations → nft_owners (trigger-maintained) | PK (token_address, token_id, wallet_address) + amount = 0 cleanup |
FINAL then argMax, never a bare WHERE sign = 1.
Full schema
Below is the complete read table this recipe produces — one row per holding observation. Keep the columns you need and drop the rest (see Schema & flexibility).token_id and amount are stored as text / wide numeric because full-range uint256 ids and counts overflow standard numeric types.
ClickHouse — fact_nft_owners_by_contract
ClickHouse — fact_nft_owners_by_contract
sign column drives reorg collapsing — read with FINAL then argMax, or a sign-aware aggregate. A single-node setup can use CollapsingMergeTree(sign) without the replication path.Postgres — nft_owner_observations + nft_owners
Postgres — nft_owner_observations + nft_owners
nft_owners state table and a DELETE … WHERE amount = 0 cleanup. position is the block-level cursor used during backfill. Refresh the materialized view on a schedule with REFRESH MATERIALIZED VIEW CONCURRENTLY nft_owners;.Example reads
Current owners of a collection — latest non-zero holding pertoken_id + wallet (ClickHouse):
Modes
Shipped defaults: ClickHousehybrid (backfill → realtime), Postgres / MySQL historical (one-shot backfill). For live/reorg-safe ingestion, use ClickHouse — see the overview.
Realtime/hybrid on Postgres / MySQL is constrained because the block-level
position cursor requires a single-column unique key. 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. NFTs on Solana are SPL tokens; if a chain emits NFT transfers under the same nftTokenTransfers struct, set the Solana chain variables. The vendor_event_id already incorporates (token_id, amount) on top of log_index to stay row-unique under Solana’s repeated log indices; the owner surface it produces is identical in shape.
Fidelity gaps
This recipe returns on-chain ownership only. Fields a Moralis/nft/{address}/owners response carries that have no onchain source (they need a separate metadata/indexer service) are not populated:
name,symbol,token_uri,metadata,normalized_metadata— collection / token metadata (use an NFT Collection Metadata recipe).possible_spam, verified-collection flags — risk / curation signals.block_number_mintedand historical mint provenance beyond the backfill window.
amount is the raw on-chain holding count, not a marketplace listing. The core access key — token_address → owners with token_id, amount, contract_type — is fully sourced from nftTokenTransfers.
Related
NFTs by Wallet
The same ingest, sorted by wallet — every NFT a wallet holds.
NFT Transfers
The event-level feed behind these owner observations.
NFT Collection Metadata
The name, symbol, and token URIs this recipe leaves out.
NFT Marketplace
The use case this owner surface powers.

