Section 01 · Definition
What is ERC1155, exactly?
ERC1155 is the multi token standard. One contract holds many ids, and each id can be fungible or non fungible. It was designed for games but works anywhere you need both kinds of tokens together.
Quick answer
What is ERC1155? ERC1155 is a multi token standard defined in EIP 1155 by the Enjin team in 2018. A single ERC1155 contract holds many token ids; each id can have any supply (one for non fungible, many for fungible). The standard supports batch transfers and reads, which makes it the gas efficient choice for collections, games, and edition drops.
The neat insight behind ERC1155 is that ERC20 and ERC721 are special cases of the same idea. An ERC20 contract is a single id with a balance per address. An ERC721 contract is many ids, each with exactly one owner. ERC1155 generalises both: many ids, each with many owners, each owner with any balance. Set the supply per id to one and you have an NFT. Set it to a million and you have a fungible token. Mix them in the same contract.
This unification has practical consequences. A game can issue gold, silver, and ten thousand identical mana potions (fungible ids) alongside named legendary swords (non fungible ids) in one contract. Marketplaces and wallets render both correctly because the standard tells them how to look up balances and metadata. ERC1155 is the dominant standard in on chain gaming and in collections that ship in editions of fixed copies.
Fungibility is a property of an id, not the contract
Inside one ERC1155 contract, id 1 might have a supply of one (clearly an NFT) and id 2 might have a supply of ten thousand (clearly fungible). The standard treats them the same way at the interface level. The difference shows up only in how you display them and how marketplaces price them.
Section 02 · Interface
The complete ERC1155 interface
Six required functions in the core. Two events. One uri function for metadata. Tighter than ERC721 because there is no per token approval.
The official interface from EIP 1155, exactly as the standard defines it:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IERC165 {
function supportsInterface(bytes4 interfaceId) external view returns (bool);
}
interface IERC1155 is IERC165 {
// ── Required events ─────────────────────────────────────
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 owner, address indexed operator, bool approved);
event URI(string value, uint256 indexed id);
// ── Required view functions ─────────────────────────────
function balanceOf(address account, uint256 id) external view returns (uint256);
function balanceOfBatch(address[] calldata accounts, uint256[] calldata ids)
external view returns (uint256[] memory);
function isApprovedForAll(address account, address operator) external view returns (bool);
// ── Required state changing functions ───────────────────
function setApprovalForAll(address operator, bool approved) external;
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;
}
interface IERC1155MetadataURI is IERC1155 {
function uri(uint256 id) external view returns (string memory);
}balanceOf(account, id)
Returns how many of token id the account holds. Returns 0 if they have none. Unlike ERC721, the function does not revert for unknown ids; it just returns zero, because every id implicitly has a balance of zero until somebody mints into it.
balanceOfBatch(accounts[], ids[])
Reads multiple balances in one call. Pass parallel arrays of accounts and ids, get back the parallel array of balances. Used heavily by indexers and front ends that need to render a wallet's full inventory in a single round trip.
setApprovalForAll(operator, approved)
Authorises operator to move any of your tokens (any id, any amount) on your behalf. The only approval primitive in ERC1155; per token approvals do not exist. Marketplaces use this to enable trading without asking the user to approve every individual id.
isApprovedForAll(owner, operator)
Read view that returns whether operator currently has setApprovalForAll permission for owner. Front ends call this before initiating a transfer to decide whether to ask the user for approval first.
safeTransferFrom(from, to, id, amount, data)
Moves amount copies of one id from from to to. Always safe: if to is a contract, the function calls to.onERC1155Received and reverts if the contract does not return the right magic value. The data parameter is forwarded to the receiver hook for application specific use.
safeBatchTransferFrom(from, to, ids[], amounts[], data)
The batch counterpart. Moves parallel arrays of ids and amounts in a single call. Calls onERC1155BatchReceived on contract recipients. This is the function gaming inventories use to ship a complete loot drop in one transaction.
Section 03 · Batch Power
Batch operations: the reason ERC1155 exists
If you only ever move one token at a time, you do not need ERC1155. The standard pays for itself the moment you start moving two or more tokens together.
Concretely, a batch of three transfers in one transaction is roughly 30 to 50 percent cheaper than three single transfers, because the per transaction overhead (signature verification, intrinsic gas, calldata costs) is paid once instead of three times. The savings grow with the batch size. A drop of ten different items to a player at game start is one transaction in ERC1155 versus ten in any other standard.
Batch operations are also atomic. Either every transfer in the batch succeeds, or the whole transaction reverts. With single transfers, transaction two failing leaves transaction one already on chain. That partial success is hard to reason about and frequently the source of bugs in custom batch contracts that do not use ERC1155.
Section 04 · Build Your Own
Write your own ERC1155 from scratch
A complete, working multi token contract. Mint, batch mint, transfer, batch transfer, burn, with the receiver hook handled correctly.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IERC1155Receiver {
function onERC1155Received(
address operator, address from, uint256 id, uint256 value, bytes calldata data
) external returns (bytes4);
function onERC1155BatchReceived(
address operator, address from, uint256[] calldata ids, uint256[] calldata values, bytes calldata data
) external returns (bytes4);
}
contract MyMultiToken {
// ── State ───────────────────────────────────────────────
address public owner;
string private _uri;
mapping(uint256 => mapping(address => uint256)) private _balances;
mapping(address => mapping(address => bool)) private _operatorApprovals;
// ── Events ──────────────────────────────────────────────
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 owner, address indexed operator, bool approved);
event URI(string value, uint256 indexed id);
// ── Errors ──────────────────────────────────────────────
error NotOwner();
error NotAuthorized();
error LengthMismatch();
error InsufficientBalance();
error TransferToZero();
error UnsafeRecipient();
constructor(string memory baseUri) {
owner = msg.sender;
_uri = baseUri;
}
// ── URI / metadata ──────────────────────────────────────
function uri(uint256) external view returns (string memory) {
return _uri;
}
function setURI(string memory newUri) external {
if (msg.sender != owner) revert NotOwner();
_uri = newUri;
}
// ── Balance reads ───────────────────────────────────────
function balanceOf(address account, uint256 id) public view returns (uint256) {
return _balances[id][account];
}
function balanceOfBatch(address[] calldata accounts, uint256[] calldata ids)
external view returns (uint256[] memory)
{
if (accounts.length != ids.length) revert LengthMismatch();
uint256[] memory out = new uint256[](accounts.length);
for (uint256 i = 0; i < accounts.length; i++) {
out[i] = _balances[ids[i]][accounts[i]];
}
return out;
}
// ── Approvals ───────────────────────────────────────────
function setApprovalForAll(address operator, bool approved) external {
_operatorApprovals[msg.sender][operator] = approved;
emit ApprovalForAll(msg.sender, operator, approved);
}
function isApprovedForAll(address account, address operator) public view returns (bool) {
return _operatorApprovals[account][operator];
}
// ── Single transfer ─────────────────────────────────────
function safeTransferFrom(
address from, address to, uint256 id, uint256 amount, bytes calldata data
) external {
if (from != msg.sender && !isApprovedForAll(from, msg.sender)) revert NotAuthorized();
if (to == address(0)) revert TransferToZero();
if (_balances[id][from] < amount) revert InsufficientBalance();
_balances[id][from] -= amount;
_balances[id][to] += amount;
emit TransferSingle(msg.sender, from, to, id, amount);
_checkOnERC1155Received(from, to, id, amount, data);
}
// ── Batch transfer ──────────────────────────────────────
function safeBatchTransferFrom(
address from, address to, uint256[] calldata ids, uint256[] calldata amounts, bytes calldata data
) external {
if (ids.length != amounts.length) revert LengthMismatch();
if (from != msg.sender && !isApprovedForAll(from, msg.sender)) revert NotAuthorized();
if (to == address(0)) revert TransferToZero();
for (uint256 i = 0; i < ids.length; i++) {
uint256 id = ids[i];
uint256 amount = amounts[i];
if (_balances[id][from] < amount) revert InsufficientBalance();
_balances[id][from] -= amount;
_balances[id][to] += amount;
}
emit TransferBatch(msg.sender, from, to, ids, amounts);
_checkOnERC1155BatchReceived(from, to, ids, amounts, data);
}
// ── Mint and burn ───────────────────────────────────────
function mint(address to, uint256 id, uint256 amount, bytes calldata data) external {
if (msg.sender != owner) revert NotOwner();
if (to == address(0)) revert TransferToZero();
_balances[id][to] += amount;
emit TransferSingle(msg.sender, address(0), to, id, amount);
_checkOnERC1155Received(address(0), to, id, amount, data);
}
function mintBatch(address to, uint256[] calldata ids, uint256[] calldata amounts, bytes calldata data) external {
if (msg.sender != owner) revert NotOwner();
if (to == address(0)) revert TransferToZero();
if (ids.length != amounts.length) revert LengthMismatch();
for (uint256 i = 0; i < ids.length; i++) {
_balances[ids[i]][to] += amounts[i];
}
emit TransferBatch(msg.sender, address(0), to, ids, amounts);
_checkOnERC1155BatchReceived(address(0), to, ids, amounts, data);
}
function burn(address from, uint256 id, uint256 amount) external {
if (from != msg.sender && !isApprovedForAll(from, msg.sender)) revert NotAuthorized();
if (_balances[id][from] < amount) revert InsufficientBalance();
_balances[id][from] -= amount;
emit TransferSingle(msg.sender, from, address(0), id, amount);
}
// ── Receiver hooks ──────────────────────────────────────
function _checkOnERC1155Received(address from, address to, uint256 id, uint256 amount, bytes calldata data) internal {
if (to.code.length == 0) return;
try IERC1155Receiver(to).onERC1155Received(msg.sender, from, id, amount, data) returns (bytes4 ret) {
if (ret != IERC1155Receiver.onERC1155Received.selector) revert UnsafeRecipient();
} catch {
revert UnsafeRecipient();
}
}
function _checkOnERC1155BatchReceived(
address from, address to, uint256[] calldata ids, uint256[] calldata amounts, bytes calldata data
) internal {
if (to.code.length == 0) return;
try IERC1155Receiver(to).onERC1155BatchReceived(msg.sender, from, ids, amounts, data) returns (bytes4 ret) {
if (ret != IERC1155Receiver.onERC1155BatchReceived.selector) revert UnsafeRecipient();
} catch {
revert UnsafeRecipient();
}
}
}The structure is symmetric across single and batch operations. Every state changing function emits exactly one event (TransferSingle for one id, TransferBatch for many) and calls the matching receiver hook. The hook returns early when the destination is a wallet because wallets have no code to call. When the destination is a contract, the hook reverts unless the contract acknowledges the transfer with the documented magic value.
The OpenZeppelin variant collapses this to about a dozen lines of your own code:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract MyMultiToken is ERC1155, Ownable {
constructor(string memory uri_) ERC1155(uri_) Ownable(msg.sender) {}
function mint(address to, uint256 id, uint256 amount, bytes memory data) external onlyOwner {
_mint(to, id, amount, data);
}
function mintBatch(address to, uint256[] memory ids, uint256[] memory amounts, bytes memory data) external onlyOwner {
_mintBatch(to, ids, amounts, data);
}
function setURI(string memory newUri) external onlyOwner {
_setURI(newUri);
}
}Section 05 · Remix Deploy
Deploy and mint a multi token collection on Remix
Same six step Remix loop, slightly different test plan because you are minting both fungible and non fungible ids in one contract.
Open Remix and create MyMultiToken.sol
Visit remix.ethereum.org. Create a new file in the contracts folder. Paste the from scratch contract or the OpenZeppelin variant. Both compile fine on Remix; pick whichever you want to learn from.
Pin a metadata URI template on IPFS
Create a folder of JSON files named 1.json, 2.json, 3.json, each describing one token id. Pin the folder to IPFS. The URI you pass to the constructor uses the placeholder ipfs://YourCID/{id}.json. Wallets will substitute the hex tokenId at lookup time. The standard expects the substitution to happen client side, not on chain.
Compile with solc 0.8.20
Switch to the Solidity Compiler tab, pick version 0.8.20, click Compile. A green check appears next to the file name when the build succeeds. Read the warnings; ERC1155 frequently warns about unused parameters in receiver hooks if you write your own from scratch.
Deploy with the URI as constructor argument
Switch to the Deploy and run transactions tab. Pick Remix VM for sandbox testing or Injected Provider for Sepolia. Paste your IPFS URI template into the constructor field, e.g. ipfs://QmYourFolderCID/{id}.json. Click Deploy.
Mint a fungible id and a non fungible id
Call mint with id 1 and amount 100 (fungible currency). Call mint again with id 2 and amount 1 (a unique sword). Now your contract has two distinct token types. Call balanceOf with the same address to confirm: id 1 returns 100, id 2 returns 1.
Test mintBatch and safeBatchTransferFrom
Call mintBatch with ids [3, 4, 5] and amounts [50, 5, 1]. Watch the single TransferBatch event in the Remix terminal. Then call safeBatchTransferFrom to move some of those ids to a second account. The total cost is one transaction even though three balances change.
Section 06 · Pitfalls
Common ERC1155 mistakes to avoid
The standard is gas friendly and feature rich, but the same flexibility that makes it powerful introduces a different set of foot guns.
Forgetting that arrays must match in length
Every batch function takes parallel arrays of ids and amounts. If they do not match in length, the function should revert with LengthMismatch. Forgetting this check leads to corrupted state. The from scratch contract above checks both directions; OpenZeppelin handles it for you.
Treating ids as sequential by default
ERC1155 ids are arbitrary uint256 values. Many tutorials assume they start at 1 and increment, but the standard does not require that. You can use any id space you want, including hashed strings or composite ids. Just pick a scheme and document it.
Sharing one URI for radically different tokens
The default uri function returns the same string for every id. The {id} placeholder is substituted client side. If your token types are wildly different (one is a fungible currency, another is a unique character), make sure your JSON manifests are well typed so wallets can render both correctly.
Skipping the receiver hook on contract recipients
If you write a contract that holds ERC1155 tokens, you must implement onERC1155Received and onERC1155BatchReceived correctly. Forgetting them or returning the wrong magic value causes safe transfers to your contract to revert. OpenZeppelin's ERC1155Holder gives you a default implementation.
Section 07 · FAQ
Frequently asked questions
When should I use ERC1155 instead of ERC721?
Use ERC1155 when you need many token types in one collection (a game with currencies, items, and characters), when you want batch transfers (an edition drop, a loot box), or when you need both fungible and non fungible items together. Use ERC721 when each token is unique and you want the simplest possible model. Most pure NFT art collections still use ERC721 because of historical marketplace tooling.
Does OpenSea support ERC1155?
Yes, OpenSea, Magic Eden, Blur, and most other marketplaces fully support ERC1155 collections. The trade UX is slightly different because you specify both an id and an amount when listing. Most game items, edition drops, and TCG style cards on OpenSea are ERC1155.
How does the {id} placeholder in the URI work?
The standard says the contract returns one URI for the whole collection (or per id if you override), and the URI may contain the literal substring {id}. Clients that fetch metadata are required to substitute that placeholder with the lowercase hex representation of the tokenId, padded to 64 characters. So id 1 becomes 0000...0001 in the URL. This lets one URI template serve any number of tokens.
Can I have a maximum supply per id in ERC1155?
The standard does not enforce one, but you can add it yourself. Track a mapping from id to max supply, and revert in mint if the new total would exceed it. OpenZeppelin's ERC1155Supply extension exposes totalSupply(id) and exists(id) helpers that make this pattern easy to implement.
What happens to approvals when an ERC1155 token is transferred?
ERC1155 only supports operator approvals via setApprovalForAll. There are no per token approvals to clear, so transfers do not change any approval state. The operator stays approved across the operation. This is one of the reasons ERC1155 is simpler than ERC721 at the contract level.
Can I mix this with my existing ERC721 collection?
Not in the same contract. ERC721 and ERC1155 are different interfaces and a single contract can implement only one of them at the standard level. You can run both contracts side by side, share an art pipeline, and link them through your front end. Some projects deploy a complementary ERC1155 for in game items alongside an ERC721 for unique characters.