Sign in

ERC-721 and ERC-1155

You just shipped an ERC-20. The fungible model fits a lot of things: stablecoins, governance tokens, points systems, anything where one unit of a token is interchangeable with any other. But it doesn't fit unique items. There's no way for a single ERC-20 contract to say "Alice owns this specific token and Bob owns that one." For that you need a different shape. This lesson covers the two standards that fill the gap: ERC-721 for unique tokens, and ERC-1155 for many token types in one contract. It walks through how each one stores ownership, what they're good at, and how to pick between them.

The shape problem ERC-20 doesn't fit

ERC-20's storage model is one mapping: address to balance. That's enough to represent fungible value, where every unit of the token is interchangeable. If Alice has 500 USDC and sends 100 to Bob, her balance goes to 400 and Bob's goes up by 100. The contract doesn't need to know which 100 units moved because they're identical.

This breaks the moment you want tokens to be individually addressable. A digital painting. A specific land parcel in a virtual world. A loan position with a specific collateral lockup. Each of these has its own identity. You can't represent them as balance amounts because the question isn't "how many" but "which one."

ERC-721: one id, one owner

The core idea of ERC-721 is that each token has a unique 256-bit ID, and the contract tracks which address owns each ID at any given moment. The mapping is the inverse of ERC-20's. Instead of address to balance, it's id to address.

When Alice mints token 42, the contract sets ownerOf(42) = alice. When she transfers it to Bob, the contract sets ownerOf(42) = bob. There's never a question of "how much of token 42" because there's exactly one of token 42 by construction. The supply is one.

A contract usually represents one collection of related tokens

The standard's interface in code:

solidity
// Solidity 0.8.24, Ethereum mainnet
interface IERC721 {
    function balanceOf(address owner) external view returns (uint256);
    function ownerOf(uint256 tokenId) external view returns (address);
    function safeTransferFrom(address from, address to, uint256 tokenId) external;
    function transferFrom(address from, address to, uint256 tokenId) external;
    function approve(address to, uint256 tokenId) external;
    function setApprovalForAll(address operator, bool approved) external;
    function getApproved(uint256 tokenId) external view returns (address);
    function isApprovedForAll(address owner, address operator) external view returns (bool);
    function tokenURI(uint256 tokenId) external view returns (string memory);

    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);
}

A few things to notice. balanceOf exists, but it returns a count of how many tokens this address owns, not the IDs themselves. There's no built-in way to ask "what tokens does Alice own?" The optional ERC721Enumerable extension adds that, but it costs storage gas on every mint and transfer to maintain the index.

tokenURI(tokenId) returns a URI pointing at the token's metadata. The metadata is typically a JSON document at that URI, hosted off-chain or on IPFS. ERC-721's convention is one URI per token. That gives you per-token flexibility but means most production contracts implement a base URI plus a tokenId suffix to avoid storing thousands of strings on chain.

approve and setApprovalForAll are the two delegation mechanisms. approve(spender, tokenId) lets one address transfer one specific token. setApprovalForAll(operator, true) lets one address transfer any token the owner holds. Marketplaces use the second form because they need to list any of your tokens for sale.

ERC-1155: many ids, balances per holder

ERC-1155 generalizes the model. Each tokenId has its own balance mapping, and one contract can hold many tokenIds. The storage layout looks like a two-dimensional table: id by holder, with the cell value being how many of that id this holder owns.

When you mint 1,000,000 of token 1 to Alice, the contract sets balances[1][alice] = 1000000. When you mint 1 of token 42 to her, it sets balances[42][alice] = 1. Both ids live in the same contract. Both follow the same storage shape.

This unlocks two things. First, a single contract can represent many different token types simultaneously. A game with gold pieces, swords, and potions doesn't need three contracts. Second, the standard was designed from the start with batch operations in mind. You can mint a hundred different ids in one transaction. You can transfer twenty ids worth of inventory from one address to another in one transaction. The fixed gas cost of a transaction gets amortized across all of them.

The interface:

solidity
// Solidity 0.8.24, Ethereum mainnet
interface IERC1155 {
    function balanceOf(address account, uint256 id) external view returns (uint256);
    function balanceOfBatch(address[] calldata accounts, uint256[] calldata ids) external view returns (uint256[] memory);
    function safeTransferFrom(address from, address to, uint256 id, uint256 amount, bytes calldata data) external;
    function safeBatchTransferFrom(address from, address to, uint256[] calldata ids, uint256[] calldata amounts, bytes calldata data) external;
    function setApprovalForAll(address operator, bool approved) external;
    function isApprovedForAll(address account, address operator) external view returns (bool);
    function uri(uint256 id) external view returns (string memory);

    event TransferSingle(address indexed operator, address indexed from, address indexed to, uint256 id, uint256 value);
    event TransferBatch(address indexed operator, address indexed from, address indexed to, uint256[] ids, uint256[] values);
    event ApprovalForAll(address indexed account, address indexed operator, bool approved);
    event URI(string value, uint256 indexed id);
}

Three differences from ERC-721 worth highlighting.

balanceOf takes both an account and an id. It returns the holder's balance of that specific id. There's no overload that returns a list of all ids the holder has, similar to ERC-721. The standard expects clients to discover ids off-chain through event logs.

There's no per-id approval. ERC-1155 has only setApprovalForAll. If you want a marketplace to be able to sell any of your items, you grant operator approval. There's no equivalent of approve(spender, tokenId) for a single id. This simplifies the standard but means you can't grant fine-grained per-id permissions.

The uri(id) function returns a URI for the token's metadata, similar to ERC-721's tokenURI. The convention is that the returned string can contain the literal substring {id}, which clients substitute with the lowercase hex token id padded to 64 characters. This lets one URI template serve all ids in the contract.

Storage shapes side by side

The clearest way to see the difference between the three standards is to look at their storage layouts together.

ERC-20 mapping(address => uint256) balanceOf 0xAlice 500 0xBob 200 0xCarol 300 balance per holder no token identity ERC-721 mapping(uint256 => address) ownerOf id 1 0xAlice id 2 0xBob id 3 0xAlice one owner per id no quantity, just ownership ERC-1155 mapping(uint256 => mapping(address => uint256)) balanceOf id 1, 0xAlice 500 id 1, 0xBob 200 id 42, 0xAlice 1 id 99, 0xBob 10 balance per (id, holder) many ids in one contract Each standard picks a different storage shape for ownership.

ERC-20 stores balances per holder, with no concept of token identity. ERC-721 stores an owner per id, with no concept of quantity. ERC-1155 stores both, a balance per (id, holder) pair. The first two are special cases of the third.

The other side of this is gas cost. ERC-721's mapping is shallower, so reads and writes are slightly cheaper than ERC-1155's nested mapping. The difference is small, but for high-frequency operations it can add up. ERC-1155 trades that small per-write cost for the structural benefits of batch operations.

Batch operations

This is the central feature ERC-1155 added that ERC-721 doesn't have. The standard ships with safeBatchTransferFrom, and the OpenZeppelin implementation adds _mintBatch and _burnBatch internal functions. All three take arrays of ids and amounts and process them in a single transaction.

ERC-721: three separate transactions tx 1 transferFrom( alice, bob, 42) tx 2 transferFrom( alice, bob, 43) tx 3 transferFrom( alice, bob, 44) base transaction overhead paid three times ERC-1155: one batch transaction tx 1 safeBatchTransferFrom( alice, bob, [42, 43, 44], [1, 1, 1], "") base transaction overhead paid once ERC-1155 folds many token movements into a single transaction.

Why this matters. Every Ethereum transaction has a base cost of 21,000 gas, regardless of what it does. On top of that you pay for whatever the transaction's code consumes. If you're transferring three NFTs from Alice to Bob using ERC-721, you pay 21,000 base gas three times, plus the per-transfer execution cost. With ERC-1155's batch transfer, you pay the base cost once.

The savings scale with batch size. Three transfers saves you a meaningful chunk. Twenty transfers saves close to half. A hundred transfers saves enough that batched and unbatched are different orders of magnitude. For projects that distribute large numbers of tokens, like game item drops, airdrops to thousands of wallets, or marketplace settlement of dozens of trades, this difference is real money.

Batch operations also give you atomicity. With three separate ERC-721 transfers, any one could revert independently. The first two might succeed and the third might fail, leaving you in a half-applied state. ERC-1155's batch is one transaction. Either all the transfers happen or none do. For game item bundles where partial fulfillment would corrupt the user's inventory, this matters.

The three flavors of tokenId

ERC-1155 is sometimes described as supporting fungible, non-fungible, and semi-fungible tokens. This is true, but it isn't a feature the contract enforces. It's a property that emerges from how many of each id you mint.

Contract: GameItems (ERC-1155) id name total supply holders flavor 1 GOLD 1,000,000 Alice 500 Bob 200 Carol 100 ... many more fungible 42 SWORD_OF_DAWN 1 Alice 1 non-fungible 99 POTION_LE 50 Alice 10 Bob 20 Dave 15 Eve 5 semi-fungible All three live in the same balances mapping. The label depends only on supply. Fungibility in ERC-1155 emerges from supply alone.

A tokenId with a supply in the millions, spread across many holders, behaves like a fungible token. The id might be in-game currency or a stablecoin equivalent. Holders care about the total amount they hold, not which units they hold.

A tokenId with a supply of exactly one is non-fungible. There's only one of it in existence, and only one address can hold it at a time. The id might be a unique artifact, a one-of-a-kind item, a deed.

A tokenId with a supply of, say, 50, is semi-fungible. The 50 units are interchangeable within their id but distinct from other ids. The id might be a limited-edition item, a ticket tier, a serialized bundle of consumables.

The contract has no idea which category an id falls into. It stores balances[id][holder] for all of them. The same code path that moves your gold can move your sword. The same event gets emitted. From the contract's perspective there are just ids with balances.

Picking between them

The decision is usually clear from the project's shape.

You want ERC-721 when each token genuinely has its own identity and the project is one collection of similar but distinct items. A PFP project. A 1-of-1 art piece. A registry of named items. The per-id metadata is the point. You also want it when on-chain enumeration of "what does this address own" matters, since the ERC721Enumerable extension gives you that.

You want ERC-1155 when you have multiple token types from the start, or when batch operations are part of the design. Game inventories with currencies, weapons, and consumables. Event ticketing where different tiers are different ids but tickets within a tier are interchangeable. Airdrops where you'll be doing many transfers at once. Multi-asset marketplaces where one contract needs to handle a long tail of token types.

Some projects use both. A flagship ERC-721 collection for the headline assets, with an ERC-1155 alongside for accessories, consumables, or fractional editions. They compose fine within a single dApp.

The choice is rarely about gas alone. For a small collection with infrequent transfers, the savings from ERC-1155's batch operations don't matter. For a high-volume project, they matter a lot. The deeper question is whether your tokens are best modeled as "a registry of unique items" or "balances across many item types." Pick whichever sentence describes your project.

Things to know before shipping

A few practical notes that catch people on first deployment.

ERC-1155 has no name() or symbol(). The standard doesn't include them. ERC-721 inherited them from ERC-20 because the early NFT ecosystem assumed every collection had a single human-readable name. ERC-1155 doesn't, because a single contract might hold many unrelated token types. Some implementations add name() and symbol() as optional extensions, but you can't rely on them being present.

The {id} URI substitution is a client-side convention. The contract returns the URI string as-is, including the literal {id} placeholder. Wallets, marketplaces, and indexers substitute the hex-encoded tokenId before fetching. If you serve metadata from your own backend, make sure your endpoint handles the substituted form.

Receiver hooks are required for safe transfers. Both standards have a safeTransferFrom variant that calls a hook on the receiving contract. ERC-721 calls onERC721Received. ERC-1155 calls onERC1155Received or onERC1155BatchReceived. If the recipient is a contract that doesn't implement the right hook, the transfer reverts. This is intentional. It prevents tokens from being trapped in contracts that have no logic to handle them.

Operator approval is the only delegation in ERC-1155. There's no per-id approve. If a marketplace wants to list any of your items, you grant operator approval over the whole contract. Revoking access from a single item means revoking the entire operator's authority. Plan UX around this.

OpenZeppelin's ERC1155 doesn't track per-id total supply by default. The ERC1155Supply extension adds it. If you need to query "how many of id X exist," include the extension. Without it, total supply is something you'd have to compute off-chain from TransferSingle and TransferBatch events.

ERC-721 and ERC-1155 are different shapes for representing tokens that aren't pure ERC-20 balances. ERC-721 is a registry of unique items, one id per owner, with rich per-token metadata. ERC-1155 is a general balances table indexed by both id and holder, designed for batch operations and multi-token contracts. The right choice falls out of what your tokens look like in code. If you're unsure, sketch the storage layout in pseudocode before picking the standard.