BlockchainSolidity16 min readUpdated

ERC-1155 With OpenZeppelin: A Beginner Tutorial With Code

By Mudassir Khan — Agentic AI Consultant & AI Systems Architect, Islamabad, Pakistan

Cover illustration for: ERC-1155 With OpenZeppelin: A Beginner Tutorial With Code

Quick answer

What is ERC1155 and why use OpenZeppelin for it? ERC1155 is an Ethereum token standard where a single smart contract can issue and track many distinct token types in parallel, each identified by a numeric ID and tracked by per address balance. OpenZeppelin provides an audited ERC1155.sol implementation that handles balances, batch transfers, safe transfer callbacks, and metadata URIs for you, so you only write the parts unique to your project: who can mint, what the token IDs mean, and where the metadata lives.

Comparison of ERC-20, ERC-721 and ERC-1155 token standards across balance shape, batch transfer and metadata
Standards at a glance — ERC1155 generalises both ERC20 and ERC721 with one balance map and atomic batch transfers.

Section 01 · Motivation

Why ERC1155 exists

Before ERC1155 you picked between ERC20 for currency and ERC721 for unique items. Both forced separate contracts. ERC1155 collapses them into one.

For fungible currency you used ERC20. One contract, one token type, balances stored as a mapping from address to amount. Simple, but if you wanted ten currencies you deployed ten contracts.

For unique collectibles you used ERC721. One contract per collection, every token tracked by a unique ID. Great for art, but if you wanted to mint a thousand identical sword icons for a game, ERC721 forced you to give each sword its own ID and pay storage costs for a thousand unique entries.

ERC1155 collapses both patterns into one contract. The trick is a two dimensional balance mapping. Instead of balanceOf[address] returning a single number, ERC1155 stores balanceOf[address][tokenId]. A single contract can now host gold coins (fungible, you own 1,200), silver coins (fungible, 50 of them), a sword of fire (semi fungible, 3 copies), and a legendary armor (unique, one of one), all inside one deployed contract, all transferable in a single batch call.

Games were the original motivation (Enjin proposed the standard for in game items), but ERC1155 is now used for fractionalized art, event tickets, packaged NFT drops, DeFi receipts, and any product where you need many token kinds without many contracts.

Section 02 · Comparison

ERC1155 vs ERC20 vs ERC721

A quick mental model before we write code. If you only need one currency, stay with ERC20. If you only need genuinely unique art, ERC721 is fine. The moment your catalog mixes quantities, ERC1155 is the standard.

CapabilityERC20ERC721ERC1155
Token types per contract1Many (each unique)Many (fungible or unique)
Balance storageaddr → uinttokenId → owneraddr → tokenId → uint
Batch transferNoNoYes (atomic)
Metadata URINone standardtokenURI(id)uri() + {id}
Typical useCurrenciesOne of one NFTsGame inventories, mixed catalogs

The rest of this tutorial assumes you have Node.js installed and a Solidity workflow (Foundry, Hardhat, or Remix). Every contract below targets Solidity ^0.8.20 and OpenZeppelin Contracts version 5.x.

Section 03 · Setup

Install OpenZeppelin

One command per workflow. Remix users can skip the install step entirely.

In a Foundry project:

bash
forge install OpenZeppelin/openzeppelin-contracts

In a Hardhat or Node project:

bash
npm install @openzeppelin/contracts

In Remix, you import directly from npm with no install step:

solidity
import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";

Always check the imported version matches the documentation you are reading. The v5.x line dropped the older _setupRole pattern and now uses AccessControl differently, so copy paste from a 2022 tutorial will not compile.

Section 04 · Example 1

The simplest possible ERC1155 contract

Twenty lines. Inherits ERC1155 and Ownable. One mint function gated by the deployer.

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract MyFirstMultiToken is ERC1155, Ownable {
    // ERC1155 needs a URI template at construction.
    // Ownable v5 wants the initial owner explicitly.
    constructor(string memory uri_)
        ERC1155(uri_)
        Ownable(msg.sender)
    {}

    // Only the contract owner can mint. The "data" bytes are an
    // ERC-1155 extension point, almost always empty for simple cases.
    function mint(address to, uint256 id, uint256 amount) external onlyOwner {
        _mint(to, id, amount, "");
    }
}

The import pulls in the OpenZeppelin base contract. It already implements every required ERC1155 function: balanceOf, balanceOfBatch, setApprovalForAll, isApprovedForAll, safeTransferFrom, safeBatchTransferFrom, and supportsInterface. You inherit all of that for free.

The contract inherits both ERC1155 and Ownable. The latter gives you an owner slot and an onlyOwner modifier so the deployer is the only address allowed to mint. The constructor passes the URI template through to the parent and sets the deployer as the first owner. The Ownable(msg.sender) argument is required in OpenZeppelin v5; older v4 examples omit it and will not compile.

_mint(to, id, amount, "") is the internal helper from OpenZeppelin. It updates the balance mapping, fires the TransferSingle event, and calls onERC1155Received on the recipient if the recipient is a contract. The trailing "" is the optional data payload, almost always empty for simple mints.

Deploy this contract with a placeholder URI, call mint(0xYourAddress, 0, 100), and you have just minted one hundred copies of token ID 0 to yourself. Call mint(0xYourAddress, 1, 1) and you own one copy of a different token ID inside the same contract.

Common beginner mistake: forgetting the data parameter

Solidity will not let you call _mint(to, id, amount) without the fourth argument. The compiler will complain Wrong argument count. Always pass empty bytes for the data parameter unless you have a custom hook on the receiver side.

Section 05 · Example 2

Named token IDs and pre-minted supply

Numeric token IDs are easy to mistype. Alias them with uint256 public constant declarations and seed the deployer wallet inside the constructor.

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract GameCurrencies is ERC1155, Ownable {
    uint256 public constant GOLD   = 0;
    uint256 public constant SILVER = 1;
    uint256 public constant GEMS   = 2;

    constructor()
        ERC1155("https://example.com/metadata/{id}.json")
        Ownable(msg.sender)
    {
        _mint(msg.sender, GOLD,   10_000, "");
        _mint(msg.sender, SILVER, 50_000, "");
        _mint(msg.sender, GEMS,   100,    "");
    }
}

The contract pre mints an initial supply right inside the constructor. The deployer wallet ends up holding all three currencies at deployment time, which is convenient for a faucet pattern (you, the deployer, hand out tokens to players manually or through a separate distribution contract).

uint256 public constant declarations cost nothing extra at deploy time. The compiler inlines them as immutable constants. They give you readable code (GOLD instead of 0) and your contract still serves any token ID a client wants to query.

Best practice: reserve token ID ranges

If you know your contract will grow, pick token ID ranges that leave room. For example: 0 to 99 for currencies, 100 to 9,999 for consumable items, 10,000 to 99,999 for equipment, 100,000 and up for unique collectibles. This is just convention, but it keeps an index built on top of your contract sane.

Section 06 · Example 3

Metadata URIs and the {id} placeholder

ERC1155 stores one URI template instead of N URLs. Wallets substitute {id} with the padded hex token ID when they fetch metadata.

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract CollectionWithMetadata is ERC1155, Ownable {
    constructor()
        ERC1155("https://my-game.example/api/items/{id}.json")
        Ownable(msg.sender)
    {}

    function mint(address to, uint256 id, uint256 amount) external onlyOwner {
        _mint(to, id, amount, "");
    }

    // Optional: let the owner update the URI template after deploy
    function setURI(string calldata newuri) external onlyOwner {
        _setURI(newuri);
    }
}

If you call mint(alice, 5, 1) on this contract, then call the read function uri(5), you get back the literal string https://my-game.example/api/items/{id}.json. The client (OpenSea, Etherscan, your own dApp) is responsible for replacing {id} with 0000000000000000000000000000000000000000000000000000000000000005 and fetching that URL.

Your off chain service therefore needs to respond at the padded hex path with a JSON document like:

json
{
  "name": "Iron Sword",
  "description": "A simple iron sword.",
  "image": "https://my-game.example/img/iron-sword.png",
  "decimals": 0,
  "properties": { "attack": 5, "rarity": "common" }
}

Three URI mistakes to avoid

One: treating {id} as a Solidity feature. It is a client side substitution; the contract returns the string verbatim. Two: hard coding HTTPS metadata for permanent collections. Use IPFS or Arweave so assets outlive your hosting account. Three: reaching for per token URIs. ERC1155 does not need them; the single template handles unlimited IDs.

ERC-1155 single mint flow from caller through OpenZeppelin _mint into the recipient balance map
Single mint flow — entry function, OpenZeppelin _mint helper, storage write, TransferSingle event.

Section 07 · Example 4

Batch minting in one transaction

This is the example most beginners come to ERC1155 for. Minting many token IDs in a single transaction with one signature, one gas overhead, and one event.

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract BatchMintExample is ERC1155, Ownable {
    constructor()
        ERC1155("https://example.com/api/items/{id}.json")
        Ownable(msg.sender)
    {}

    /// @notice Mint many token IDs to one recipient in one transaction.
    function mintBatch(
        address to,
        uint256[] calldata ids,
        uint256[] calldata amounts
    ) external onlyOwner {
        _mintBatch(to, ids, amounts, "");
    }
}

Calling mintBatch(alice, [0,1,2,3,4], [100, 50, 25, 10, 1]) in one transaction gives Alice 100 copies of token ID 0, 50 of ID 1, 25 of ID 2, 10 of ID 3, and 1 of ID 4. One signed transaction. One gas payment for transaction overhead (the 21000 base). The per token cost is then dominated by the storage writes, which scale linearly. Batch minting 10 token IDs typically costs around 30 to 40 percent less than 10 separate _mint calls because you skip 9 transaction overheads and reuse the contract context once.

Under the hood, OpenZeppelin’s _mintBatch validates that ids.length == amounts.length (reverts with ERC1155InvalidArrayLength otherwise), updates _balances[id][to] for each pair in a tight loop, and fires one TransferBatch event containing both arrays. Marketplaces and indexers listen for TransferBatch instead of N individual TransferSingle events.

Best practice: cap the batch array length

A naive mintBatch accepts unlimited array lengths. In practice, gas limits cap you around 200 to 500 ids per batch. For production contracts, add an explicit upper bound like require(ids.length <= 100). A clear revert reason is friendlier than an opaque out of gas error.

Atomic batch transfer of multiple ERC-1155 token IDs from sender to recipient in one transaction
safeBatchTransferFrom moves many IDs in one tx. One signature, one gas fee, one TransferBatch event.

Section 08 · Example 5

Safe transfers and the receiver contract pattern

If the recipient is a contract, ERC1155 requires it to acknowledge it can handle the token. Otherwise the transfer reverts. ERC1155Holder is OpenZeppelin's drop in receiver.

For a contract that accepts ERC1155, here is the minimal receiver:

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol";

contract MyVault is ERC1155Holder {
    // ERC1155Holder already implements onERC1155Received and
    // onERC1155BatchReceived correctly.
    // Add your business logic here, e.g. crediting the depositor.
}

For users transferring tokens TO a contract, the call site is:

solidity
// Single transfer
multiToken.safeTransferFrom(msg.sender, address(vault), tokenId, amount, "");

// Batch transfer
multiToken.safeBatchTransferFrom(msg.sender, address(vault), ids, amounts, "");

Approvals are all or nothing

ERC1155 only supports operator level approval via setApprovalForAll(operator, true). There is no per token approval. The operator can then move every token, of every ID, that you currently or ever will own in this contract. Marketplaces use this so a buyer can list any item from your inventory without a fresh approval per item. Be careful which contracts you approve; revoke approvals to dormant marketplaces with setApprovalForAll(operator, false).

Section 09 · Example 6

A real world example: concert ticket sales

Three ticket tiers (General, VIP, Backstage), buyers pay in ETH, the organiser withdraws revenue. About thirty lines, no inheritance gymnastics.

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract ConcertTickets is ERC1155, Ownable {
    // Ticket tiers — three token IDs inside one contract.
    uint256 public constant GENERAL   = 0;
    uint256 public constant VIP       = 1;
    uint256 public constant BACKSTAGE = 2;

    // Price per tier, in wei.
    mapping(uint256 => uint256) public priceOf;

    constructor()
        ERC1155("https://my-event.example/tickets/{id}.json")
        Ownable(msg.sender)
    {
        priceOf[GENERAL]   = 0.05 ether;
        priceOf[VIP]       = 0.2 ether;
        priceOf[BACKSTAGE] = 0.5 ether;
    }

    /// @notice Anyone can buy N tickets of one tier in a single transaction.
    function buy(uint256 ticketType, uint256 quantity) external payable {
        uint256 price = priceOf[ticketType];
        require(price > 0, "ConcertTickets: unknown tier");
        require(msg.value == price * quantity, "ConcertTickets: wrong amount");
        _mint(msg.sender, ticketType, quantity, "");
    }

    /// @notice Organiser withdraws ticket revenue.
    function withdraw() external onlyOwner {
        payable(owner()).transfer(address(this).balance);
    }
}

Why this is a great ERC1155 use case: three distinct ticket types live inside one contract, each tier is fungible (a hundred General tickets are interchangeable with each other), buyers can grab multiple tickets in one transaction, and you keep the well known token interface so OpenSea or any other marketplace can list resales without extra work.

The interesting line is _mint(msg.sender, ticketType, quantity, ""). The buyer becomes the recipient. The function is payable, so it accepts ETH; the require check enforces correct payment before any mint happens. Money in, tickets out, both atomic.

priceOf is a public mapping, which Solidity auto exposes as a getter, so a front end can read priceOf(0) to display the General Admission price without a separate view function. To raise prices later, add a small setPrice(id, newPrice) function gated by onlyOwner.

Why no AccessControl, no ERC1155Supply?

Concert tickets do not need 1 of 1 enforcement (multiple tiers, many copies per tier), and there is only one organiser address that needs admin rights. Ownable is enough. Stay simple until a feature actually demands an extension. You can always add ERC1155Supply later if you decide to cap supply per tier, and AccessControl if a second admin needs to mint.

Real production tickets would add: per tier supply caps, a sale start/end window, a refund function, and an event for the indexer. Each is a few extra lines on top of this base. The point of this example is that ERC1155 fits perfectly the moment you have more than one product variant.

Section 10 · Pitfalls

Common beginner mistakes (consolidated)

The mistakes that show up over and over when developers first ship ERC1155 contracts. Memorise this list and you skip the painful debug sessions.

Mismatched arrays

_mintBatch and safeBatchTransferFrom both require ids.length == amounts.length and revert with ERC1155InvalidArrayLength otherwise. Always verify before the call.

Missing the data parameter

OpenZeppelin's _mint expects four arguments; the fourth is usually empty bytes. Beginners copy paste a three argument signature and the compiler refuses.

Unsafe sending to contracts

If you call safeTransferFrom to a contract that does not implement IERC1155Receiver, the transfer reverts. The receiving contract must inherit ERC1155Holder or implement the two callback functions manually.

Treating {id} as a Solidity feature

It is a string convention for off chain metadata servers. The contract returns the URI verbatim; the client substitutes the padded hex ID.

Forgetting setApprovalForAll scope

It grants the operator the right to move every token, of every ID, that you own in this contract. There is no per token approval. Approve only contracts you trust.

Skipping ERC1155Supply when uniqueness matters

The base ERC1155 does not track total supply by token ID. If you want 1 of 1 enforcement on chain, inherit the extension.

Copy pasting from a Solidity 0.7 era tutorial

The constructor signature for Ownable changed in OpenZeppelin v5 (now requires an explicit initialOwner). The _setupRole helper was removed in favor of _grantRole. Always match the OpenZeppelin version your imports point at.

Section 11 · Production

Best practices for production

A short checklist of habits that make an ERC1155 contract production grade.

Pin the OpenZeppelin version in package.json or foundry.toml. Do not float the version range; a minor bump can change inherited behavior. Use AccessControl with named roles rather than Ownable once more than one human or service needs to mint. It scales better and is auditable on chain via role enumeration.

Add an upper bound on batch sizes. Allowing unlimited ids.length invites out of gas reverts in production. Use ERC1155Supply if uniqueness or supply caps matter. Use ERC1155Burnable if holders should be able to burn their tokens. Pin metadata to IPFS or Arweave for collections you intend to outlast a hosting account; use a centralized HTTPS endpoint only for ephemeral or dynamic metadata.

Emit at least one custom event when you mint, beyond the standard TransferSingle and TransferBatch. A PlayerStarterPackGranted(address player, uint256 timestamp) event gives your indexer a precise hook. And audit before mainnet. ERC1155 itself is well tested at the OpenZeppelin level, but your custom mint logic, supply caps, and role assignments are new code and should be reviewed.

Section 12 · Migration

How ERC1155 differs from ERC721 in practice

If you have shipped an ERC721 collection before, three differences will catch you.

There is no tokenOfOwnerByIndex. ERC1155 does not enumerate tokens by owner on chain. If you need a list-all-tokens-this-wallet-owns feature, you index events off chain (TransferSingle, TransferBatch) or maintain your own list in a separate contract.

There is no tokenURI(id) returning a per token URL. ERC1155 returns one template via uri(id). Wallets that previously rendered ERC721 metadata from tokenURI need extra logic for ERC1155.

There is no per token approval. approve(spender, tokenId) does not exist. Only setApprovalForAll(operator, bool). Marketplaces designed for ERC721 typically need a small adapter layer to handle this. None of these are bugs. They are tradeoffs the ERC1155 authors accepted in exchange for the batch transfer and multi token capabilities.

Section 13 · FAQ

Frequently asked questions

What is ERC1155 in simple terms?

ERC1155 is an Ethereum token standard that lets one smart contract hold many different token types at once. Each token type has a numeric ID and can behave like fungible currency (many copies) or like a unique collectible (one of one). It was originally proposed by Enjin for game items.

What is the difference between ERC721 and ERC1155?

ERC721 uses one contract per collection and tracks every token as a unique ID. ERC1155 uses one contract for many collections and stores balances as a two dimensional map of address by token ID. ERC1155 also adds atomic batch transfers, which ERC721 lacks.

Can ERC1155 do everything ERC20 does?

Functionally yes for the balance side. A single token ID inside an ERC1155 contract behaves like an ERC20 balance. The difference is interface: ERC20 wallets and tools expect the ERC20 ABI, so if you want a token to plug into Uniswap or other ERC20 ecosystems, deploy ERC20.

Why does the OpenZeppelin ERC1155 URI use {id} instead of the actual ID?

To avoid storing N strings on chain when one template will do. The literal {id} placeholder is replaced by the client (wallet or marketplace) with the padded hex encoded token ID before fetching metadata. This keeps gas costs flat regardless of how many token IDs you mint.

Is ERC1155 safe to use in production?

The OpenZeppelin ERC1155.sol implementation is audited and widely deployed. The risk is almost always in your custom code: who can mint, supply caps, role assignments, and metadata immutability. Have your custom contract audited before mainnet.

How do I mint multiple ERC1155 tokens in one transaction?

Call _mintBatch(to, ids, amounts, data) from a function gated by your access control. The ids and amounts arrays must be the same length. The OpenZeppelin implementation handles balance updates and fires a single TransferBatch event.

What is setApprovalForAll in ERC1155?

It grants an operator address the right to move any token, of any ID, that you currently own or will own in this contract. ERC1155 has no per token approval; the only approval model is global per contract.

Written by Mudassir Khan

Agentic AI consultant and AI systems architect based in Islamabad, Pakistan. CEO of Cube A Cloud. 38+ agentic AI launches delivered for global founders and CTOs.

View agentic AI consulting serviceSee ChainTrust case study

Related service

Agentic AI Consulting

See scope & pricing →

Related case study

ChainTrust Compliance Engine

Read case study →

More on this topic

Need an AI systems architect?

Book a 30-minute architecture call. I will sketch the high-level design for your use case and give you an honest view of the trade-offs.

Book a strategy call →