ERC-721: The Token Standard That Powers NFTs

ERC-721 is the Ethereum token standard for non-fungible tokens. Each ERC-721 token is unique and indivisible. No two tokens are interchangeable, and you cannot split one into smaller units.

This post covers how the standard works, what you must implement to be compliant, how metadata attaches to tokens, and how ERC-721 compares to ERC-20 and ERC-1155.

What Non-Fungible Means

With ERC-20, all tokens are identical. One USDC is the same as any other USDC. You can add, subtract, and split them freely.

With ERC-721, token #1 is completely different from token #2. Each has its own identity, its own owner, and its own value. You cannot send half a token. You transfer the whole thing or nothing.

This makes ERC-721 useful for representing things that are inherently unique: artworks, domain names, game items, event tickets, and certificates of ownership.

The Core Interface

Every ERC-721 contract must implement this interface:

interface IERC721 {
    function balanceOf(address owner) external view returns (uint256);
    function ownerOf(uint256 tokenId) external view returns (address);
    function transferFrom(address from, address to, uint256 tokenId) external;
    function approve(address to, uint256 tokenId) external;
    function getApproved(uint256 tokenId) external view returns (address);
    function setApprovalForAll(address operator, bool approved) external;
    function isApprovedForAll(address owner, address operator) external view returns (bool);
}

Here is what each function does:

balanceOf returns the number of tokens an address owns. It is a count, not a value.

ownerOf takes a token ID and returns the address that owns it. Every token has exactly one owner.

transferFrom moves a token from one address to another. The caller must be the owner or have approval.

approve lets you authorize a specific address to transfer a single token on your behalf.

setApprovalForAll grants an operator permission to manage all your tokens in one call.

getApproved and isApprovedForAll are the corresponding read functions to check those permissions.

A Basic Implementation

Here is a minimal contract showing how the core mechanics work:

contract SimpleNFT {
    mapping(uint256 => address) private owners;
    mapping(address => uint256) private balances;
    mapping(uint256 => address) private tokenApprovals;

    uint256 private nextTokenId;

    function mint(address to) external returns (uint256) {
        uint256 tokenId = nextTokenId;
        nextTokenId++;

        owners[tokenId] = to;
        balances[to]++;

        return tokenId;
    }

    function ownerOf(uint256 tokenId) external view returns (address) {
        address owner = owners[tokenId];
        require(owner != address(0), "Token does not exist");
        return owner;
    }

    function transferFrom(address from, address to, uint256 tokenId) external {
        require(owners[tokenId] == from, "Not the owner");
        require(
            msg.sender == from || tokenApprovals[tokenId] == msg.sender,
            "Not approved"
        );

        balances[from]--;
        balances[to]++;
        owners[tokenId] = to;
        delete tokenApprovals[tokenId]; // Clear approval after transfer
    }
}

Three mappings power this contract. owners tracks which address holds each token ID. balances counts how many tokens each address owns. tokenApprovals stores per-token approvals.

Notice that transferFrom deletes the token approval at the end. This is required by the standard. Leaving stale approvals would let a previously approved address transfer a token after the owner has already moved it.

Token Metadata

ERC-721 has an optional metadata extension. It adds three functions:

interface IERC721Metadata {
    function name() external view returns (string memory);
    function symbol() external view returns (string memory);
    function tokenURI(uint256 tokenId) external view returns (string memory);
}

tokenURI is the most important one. It returns a URL pointing to a JSON file that describes the token. The JSON follows a widely-adopted format:

{
  "name": "My NFT #123",
  "description": "A unique digital item",
  "image": "ipfs://QmX7...",
  "attributes": [
    { "trait_type": "Background", "value": "Blue" },
    { "trait_type": "Rarity", "value": "Common" }
  ]
}

Most projects store this JSON on IPFS rather than a centralized server. The smart contract stores only a hash or a base URI. The actual data lives off-chain.

The on-chain contract only stores a reference to the metadata, not the metadata itself. If the off-chain storage disappears, the metadata is gone. IPFS with pinning services is the standard approach for long-term availability.

Safe Transfers

Beyond transferFrom, the standard defines safe transfer functions:

function safeTransferFrom(address from, address to, uint256 tokenId) external;
function safeTransferFrom(address from, address to, uint256 tokenId, bytes calldata data) external;

The difference from transferFrom is that these functions check whether the recipient can handle NFTs. If to is a contract, it must implement IERC721Receiver:

interface IERC721Receiver {
    function onERC721Received(
        address operator,
        address from,
        uint256 tokenId,
        bytes calldata data
    ) external returns (bytes4);
}

If the recipient contract does not implement this interface, the transfer reverts. This prevents tokens from getting permanently locked in contracts that have no way to move them.

When in doubt, prefer safeTransferFrom. Use transferFrom only when you know the recipient can handle ERC-721 tokens.

Required Events

ERC-721 requires three events:

event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId);
event ApprovalForAll(address indexed owner, address indexed operator, bool approved);

All three fields in Transfer are indexed, which means external applications can filter by sender, recipient, or token ID individually. Marketplaces and wallets rely on these events to track ownership history without querying every block.

The Optional Enumeration Extension

The standard also defines an enumeration extension that lets you list tokens on-chain:

interface IERC721Enumerable {
    function totalSupply() external view returns (uint256);
    function tokenByIndex(uint256 index) external view returns (uint256);
    function tokenOfOwnerByIndex(address owner, uint256 index) external view returns (uint256);
}

tokenOfOwnerByIndex lets you iterate over all tokens owned by a specific address. This is useful but expensive. It requires maintaining additional on-chain data structures, which increases gas costs on every mint and transfer. Many production contracts skip this extension and rely on off-chain indexers instead.

Design Decisions Worth Thinking About

Token ID strategy. Sequential IDs (1, 2, 3...) are simple and predictable. Random IDs prevent MEV bots from front-running mints to claim low-numbered tokens, which some collectors prefer. Pick based on whether rarity or simplicity matters more to your project.

On-chain vs off-chain data. Store as little as possible on-chain. Gas is expensive. Images, descriptions, and trait data belong off-chain. Only store what the contract logic actually needs.

Batch minting. Minting tokens one at a time is expensive. If your contract lets users mint multiple tokens, implement batch minting in a single transaction to reduce gas costs.

Do not store large metadata blobs in contract storage. Every byte stored on-chain costs gas permanently. Even a moderately sized JSON object will make your contract expensive to deploy and interact with.

ERC-721 vs ERC-20

The core difference is fungibility. ERC-20 tokens are fungible and divisible. ERC-721 tokens are non-fungible and indivisible.

PropertyERC-20ERC-721
FungibleYesNo
DivisibleYesNo
Identified byAmountToken ID
One owner per unitNoYes
Approval granularitySpending amountPer-token or all-tokens

The transfer calls reflect this difference directly:

// ERC-20: transfer an amount
token.transfer(recipient, 100);

// ERC-721: transfer a specific token
nft.transferFrom(owner, recipient, 5);

The approval model is also different:

// ERC-20: approve a spending limit
token.approve(spender, 1000);

// ERC-721: approve transfer of one specific token
nft.approve(spender, 5);

// ERC-721: approve an operator for all tokens
nft.setApprovalForAll(operator, true);

Gas costs differ too. ERC-20 transfers update two balance values. ERC-721 transfers update an ownership mapping, update two balance counters, and clear an approval. ERC-721 transfers consistently cost more gas.

ERC-721 vs ERC-1155

ERC-1155 is a multi-token standard. One ERC-1155 contract can hold both fungible and non-fungible tokens at the same time. It also supports batch transfers in a single transaction:

// ERC-1155: transfer multiple token types at once
contract.safeBatchTransferFrom(from, to, [id1, id2, id3], [amount1, amount2, amount3], data);

// ERC-721: requires one transaction per token
nft.transferFrom(from, to, tokenId1);
nft.transferFrom(from, to, tokenId2);
nft.transferFrom(from, to, tokenId3);

ERC-1155 is more gas-efficient when you need to move many tokens at once. It is also more flexible when a project needs both fungible items (currency, materials) and unique items (characters, land) in the same contract.

The tradeoff is complexity. ERC-1155 is harder to implement correctly and has less uniform support across wallets and marketplaces than ERC-721.

When to Use Each Standard

Pick ERC-20 when each unit is identical and you want divisibility. Currencies, governance tokens, and reward points belong here.

Pick ERC-721 when each item is unique and indivisible. Digital art, collectibles, domain names, and certificates of ownership are the right fit.

Pick ERC-1155 when you need both token types in the same system, or when batch operations matter for gas efficiency. Game inventories with weapons, currency, and unique characters are the typical use case.

Security Basics

A few checks every ERC-721 contract should have:

Verify a token exists before any operation:

require(owners[tokenId] != address(0), "Token does not exist");

Verify the caller has permission before any transfer:

require(
    msg.sender == owner ||
    tokenApprovals[tokenId] == msg.sender ||
    operatorApprovals[owner][msg.sender],
    "Not authorized"
);

Block transfers to the zero address:

require(to != address(0), "Transfer to zero address");

Use reentrancy guards on any function that calls external contracts, including safeTransferFrom. The callback to onERC721Received is an external call, and the recipient contract can execute arbitrary code before your function returns.

Summary

ERC-721 gives you a clear, well-adopted way to create unique digital assets on Ethereum. The standard handles ownership tracking, transfer permissions, and safe transfer callbacks. Metadata stays off-chain. The on-chain contract stores references, not data.

It is not the most gas-efficient option and it lacks the batch capabilities of ERC-1155. But for projects where uniqueness is the core property, ERC-721 remains the default choice with the broadest support across tooling, wallets, and marketplaces.