Question it answers
“Give me every NFT transfer: by contract 0x…, by wallet 0x…, or for a single (contract, token_id).”
A single recipe serves all three access paths. It mirrors the on-chain subset of Moralis GET /nft/{address}/transfers (by contract), GET /{address}/nft/transfers (by wallet), and GET /nft/{address}/{token_id}/transfers (single token). It carries no off-chain enrichment: the source array has no collection metadata or address labels (see Fidelity gaps).
What you get
One row per NFT transfer, mapped straight from Moralis-indexed, normalized per-block onchain data:Source
The transform reads one per-block array and lands one row per transfer:nftTokenTransfers
Fields map straight from the source struct: tokenAddress, tokenId (as a string), fromAddress, toAddress, amount, tokenType (→ contract_type), and initiatedBy. There’s no price or metadata join; this is a flat event stream.
Destination
ClickHouse uses the collapsing log-table pattern (see the recipes overview) so chain reorganizations self-correct: the
+1/−1 reorg pair for a row shares an identical key and collapses cleanly. The fact table’s sort key is contract-first, so by-contract is a prefix scan on token_address and by-token-id a prefix scan on (token_address, token_id). By-wallet reads are served by bloom_filter data-skipping indexes on from_address and to_address.
Full schema
Below is the complete read table this recipe produces. Keep the columns you need and drop the rest (see Schema & flexibility).token_id and amount are stored at full uint256 width: token_id as text (hash-derived ENS / ERC-1155 ids routinely exceed any SQL numeric precision), amount as a wide decimal.
ClickHouse, fact_nft_transfers
ClickHouse, fact_nft_transfers
sign column drives reorg collapsing: read with FINAL or sum(sign), never a bare WHERE sign = 1. A single-node setup can use CollapsingMergeTree(sign) without the replication path.Postgres, nft_transfers
Postgres, nft_transfers
VARCHAR(80) for token_id and equivalent ascending composite keys. position is the block-level cursor used during backfill.Example reads
All transfers of an NFT contract, newest first (ClickHouse):Modes
Shipped defaults: ClickHousehybrid (backfill → realtime), Postgres / MySQL historical (one-shot backfill). For live/reorg-safe ingestion, use ClickHouse (see the overview).
The realtime reorg path needs a single-column
UNIQUE on the position column, but position is block-level (many transfers share one block), so array-expanded transfer rows can only carry a composite unique. Run realtime / hybrid on ClickHouse: its log table corrects reorgs per-block via the collapsing companion table. The Postgres / MySQL shapes here are intended for historical backfill.Multichain
The recipe is chain-parametrized: point it at any supported EVM chain or Solana. On Solana the samelogIndex can be assigned to multiple events in one instruction, so the event identity is widened with (from_address, to_address, token_address, token_id, amount) to keep rows distinct; the transfer log it produces is identical in shape.
Fidelity gaps
The recipe lands only what thenftTokenTransfers array carries. Fields a Moralis NFT transfers endpoint surfaces that have no onchain source in this array are omitted:
- Collection metadata (
token_name,token_symbol): these come from the NFT contract / a metadata indexer, not the transfer event. Out of scope for a flat transfer log; see NFT Collection Metadata for the contract-metadata pattern. - Address labels / entity tags (
from_address_label,to_address_entity, …): off-chain labels from a separate labelling service, not on-chain data. value: the native value attached to the transfer transaction isn’t carried on the transfer event; omitted.amount(the ERC-1155 quantity) is present.- Pre/post NFT balances: the recipe omits the balance surface to stay a flat event stream.
Migrating from the REST API
This recipe replaces the wallet NFT transfers REST endpoint. Data Feeds are not 1:1 replicas of the old responses: you land the underlying data in your own database and reconstruct the response shape with a query. Below: what replaces the endpoint, the field mapping (exact = straight from the chain, calculated = derived from real trades, very close, add yourself = off-chain signal not in the feed), and the endpoint-specific gotchas.GET /:address/nft/transfers
A wallet’s NFT transfer history. Replaced by this recipe’snft_transfers table: a wallet’s transfers are the rows where it appears on either side, and the old endpoint’s contract_addresses, from_block/to_block, and order parameters become plain WHERE / ORDER BY clauses.
The reconstruction query (Postgres; adapt to your destination):
- Direction is yours to derive: the feed has no
send/receiveflag; compare the wallet tofrom_address/to_addressas above. The recipe indexes both sides, so the OR match stays fast. - The address labels and spam/verification flags are off-chain signals with no onchain source: drop them, bring your own lists, or layer in a label source.
token_idis auint256stored as text; compare as strings, never as numbers.- Backfill depth is your choice: transfers are an event list, not current state, so index only as much history as your app serves.
Related
NFT Trades
Marketplace sales with price, the trade-priced complement to raw transfers.
NFT Marketplace
The use case these NFT recipes power.

