Section 01 · Definition
What mapping with struct actually means
A mapping with struct is one declaration that gives you a key to record store: each key resolves to an entire bundle of fields, not just one value.
Quick answer
What is mapping with struct in Solidity? Mapping with struct combines two primitives into Solidity's most used storage pattern. You define a struct that bundles related fields, then declare a mapping whose values are that struct. Every key gives you the full record. ERC20 balances, ERC721 ownership, lending positions, and voting registries all use this shape. It is the default way to model any per user state on chain.
The pattern shows up the moment your data outgrows a single value per key. A balance is one number, so mapping(address => uint256) works. A voter has a weight, a flag for whether they have voted, and the option they picked. That is three fields, so you bundle them into a struct and map to that struct.
// Bundle the related fields into one named record
struct Voter {
uint96 weight; // voting weight (delegated tokens, NFTs held, etc.)
bool voted; // has this address already voted?
uint8 choice; // index of the proposal they voted for
}
// One mapping keyed by address gives you a Voter for any address
mapping(address => Voter) public voters;With those two declarations the contract now has an unbounded sparse store: every possible Ethereum address already has a Voter slot waiting for it, prefilled with the default values (zero, false, zero). You only pay to read or write the slots you actually touch.
If structs and mappings on their own are still fuzzy, the two foundational reads are mapping in Solidity and structs in Solidity. This guide assumes you know the basics of both and focuses on how they work together.
Section 02 · How it lives in storage
The anatomy of a mapping to a struct
Solidity does not store the struct contiguously the way a C array would. It computes a base slot from the key, then lays the struct fields out starting at that base.
When you write voters[user].weight = 100, the compiler computes keccak256(user || slotOfVoters) to find the base slot. The Voter struct in the example above fits into a single slot because its three fields together are under 32 bytes. A struct with two uint256 fields would occupy two slots, the second at base plus one.
Two consequences flow from this layout. First, reading a single field is no more expensive than reading a single mapping value would be — the EVM still does one storage read. Second, replacing every field of a record costs as many storage writes as the number of slots the struct occupies. Tight packing matters.
Storage layout is positional, not by name
The compiler assigns each struct field to a slot offset based on its declaration order. Reorder the fields and the layout changes. This is fine for a fresh contract but breaks any upgrade pattern that reuses the existing storage. If you maintain an upgradeable contract, treat the struct field order as part of the public interface.
Section 03 · The voting registry
A worked example: governance with mapping plus struct
On chain voting is the cleanest case study for this pattern. You need per voter state and per proposal state, and both are natural mapping to struct shapes.
Start by modelling the data. Every address that can vote has a weight, a flag for whether they have already voted, and a choice. Every proposal has a name and a running tally. That gives you two structs and two mappings:
struct Voter {
uint96 weight; // voting power
bool voted; // has voted at all?
uint8 choice; // index of the proposal they picked
address delegate; // optional: who they delegated to
}
struct Proposal {
bytes32 name; // short identifier
uint128 voteCount; // running tally
uint64 startsAt; // unix timestamp
uint64 endsAt; // unix timestamp
}
mapping(address => Voter) public voters;
mapping(uint256 => Proposal) public proposals;
uint256 public nextProposalId;The chairperson opens registration, hands out weight, and creates proposals. Voters either cast a direct vote or delegate to another address. The contract keeps every state transition in the two mappings.
function giveRightToVote(address voter, uint96 weight_) external onlyChair {
Voter storage v = voters[voter];
require(!v.voted, "already voted");
require(v.weight == 0, "already registered");
v.weight = weight_;
}
function delegate(address to) external {
Voter storage sender = voters[msg.sender];
require(sender.weight != 0, "no right to vote");
require(!sender.voted, "already voted");
require(to != msg.sender, "self-delegation");
// Walk the delegation chain to its end
while (voters[to].delegate != address(0) && voters[to].delegate != msg.sender) {
to = voters[to].delegate;
}
require(to != msg.sender, "delegation loop");
sender.voted = true;
sender.delegate = to;
Voter storage target = voters[to];
if (target.voted) {
// Already voted → top up the chosen proposal directly
proposals[target.choice].voteCount += sender.weight;
} else {
target.weight += sender.weight;
}
}
function vote(uint8 proposalId) external {
Voter storage v = voters[msg.sender];
require(v.weight != 0, "no right to vote");
require(!v.voted, "already voted");
v.voted = true;
v.choice = proposalId;
proposals[proposalId].voteCount += v.weight;
}Notice the use of Voter storage v. Reading or writing several fields of the same record? Take a storage reference once and reuse it. The compiler emits one set of slot calculations instead of repeating them per field. It also keeps the code readable.
This is the same shape that finds the winner across an array of proposals — the tally loop is a natural follow up that gets its own treatment in arrays with loops in Solidity.
Section 04 · Other patterns
Five more places mapping plus struct earns its keep
The voting case is one of many. Here are five other production patterns that use the same shape, each with the minimum struct needed.
User profile registry
Map an address to a Profile struct that holds username, joined timestamp, reputation score, and a flag for whether they are KYC verified. One read returns the whole identity card. ENS-style naming uses this exact shape under the hood.
Lending positions
Map an address to a Position struct that holds collateral amount, debt amount, last accrual block, and an open flag. Aave, Compound, and every fork follow the same model. The mapping makes per account lookups O(1) so liquidations stay cheap.
ERC721 token records
Map a tokenId to a TokenInfo struct holding the current owner, mint timestamp, royalty basis points, and a frozen flag. The standard ERC721 only requires ownerOf, but adding the struct gives you per token metadata without a second contract.
Role based access control
Map an address to a Role struct holding flags for admin, operator, pauser, and the timestamp the role was granted. OpenZeppelin's AccessControl uses bitfields under the hood, but a struct version is more readable for small role sets.
Auction or bid registry
Map an auction id to an Auction struct holding seller, top bidder, current bid, end time, and settled flag. Bidders update one mapping entry per auction. Reading the full auction state is one storage operation across packed fields.
Spot the shape: a key that uniquely identifies the entity, plus three to six fields that describe its current state. That is the sweet spot for mapping with struct. Past six or seven fields you should consider splitting into two structs or moving rarely accessed fields into a separate mapping.
Section 05 · Storage packing
Order the struct fields to save real gas
The compiler packs adjacent fields under 32 bytes into the same storage slot. Reorder a struct and you can cut the cost of a fresh write by half or more.
The rules are mechanical. A storage slot is 32 bytes. The compiler walks the struct fields in order, placing each one at the next available offset. If the next field fits in the remaining bytes of the current slot, it goes there. If it does not fit, the compiler advances to a new slot.
// BAD — three slots
struct VoterUnpacked {
uint256 weight; // slot 0 (32 bytes)
bool voted; // slot 1 (only 1 byte used; 31 wasted)
uint256 choice; // slot 2 (advances because slot 1 cannot hold uint256)
}
// GOOD — one slot
struct VoterPacked {
uint96 weight; // 12 bytes
bool voted; // 1 byte
uint8 choice; // 1 byte
// 18 bytes still free in this slot
}Two things to remember when sizing the fields. First, you must keep the range of values your protocol needs. uint96 tops out around 7.9 octillion — more than enough for token weights but not for raw wei balances if you intend to track them at full ETH precision. Second, every read and write to a packed field still costs the standard cost for the whole slot — packing only saves on the number of distinct slots the struct touches.
A 65 percent gas saving on cold writes
A fresh storage write costs 22 100 gas per slot. The unpacked Voter from above costs 22 100 times three (66 300 gas) on the first registration. The packed version costs 22 100 once. Across 1 000 voters at 50 gwei, that is 2.2 million gas saved — roughly 0.11 ETH at typical mainnet conditions. For early stage protocols this is real money.
Section 06 · Pitfalls
Four traps that bite first-time users
Each of these has cost real protocols real money. They are easy to avoid once you know they exist.
delete leaves the slot in an unexpected state
delete voters[user] resets every field to its default — zero, false, the zero address. It does not refund the original storage write fully and it does not remove the entry from any parallel array you maintain for enumeration. Update the array and the mapping together.
Partial writes still touch the full slot
Updating just one field of a packed struct rewrites the entire slot under the hood. There is no per field refund. Group writes that always happen together so you only pay once. Splitting them across separate transactions doubles the cost.
Public getters do not return mappings inside structs
If you put a mapping field inside a struct, the auto generated public getter cannot return it. You must write an explicit getter. The compiler will compile the contract — it just silently omits the mapping field from the public ABI.
Mapping has no built in enumeration
voters does not let you list every voter. If you need to iterate, maintain a parallel address[] array of registered voters and walk it. Add a uint256 index inside the Voter struct to enable efficient swap and pop removal.
Section 07 · Nested patterns
Mapping inside a struct: the second level lookup
Sometimes one record needs its own keyed sub store. Solidity lets you put a mapping inside a struct, and that unlocks a powerful two level pattern.
The classic case is a token vault that supports many holders, each of whom owns shares across many strategies. The struct holds the holder’s metadata; the inner mapping holds the share balance per strategy.
struct Holder {
uint64 joinedAt;
bool paused;
mapping(uint256 => uint256) sharesByStrategy; // strategyId => shares
}
mapping(address => Holder) private holders;
function shareBalance(address user, uint256 strategyId)
external
view
returns (uint256)
{
return holders[user].sharesByStrategy[strategyId];
}Two rules apply. First, the inner mapping cannot live in memory — the field must be inside a storage struct. Second, you have to write your own getter; the auto generated one cannot serialize a mapping. In exchange you get a clean two level address: an outer key picks the record, an inner key picks the bucket inside that record.
ERC20’s allowance is the simplest version of the same idea, but it skips the struct and uses a nested mapping directly: mapping(address => mapping(address => uint256)). Either approach works. Use the struct form when the outer record has fields beyond just the inner mapping; use the bare nested mapping when it does not.
Section 08 · Complete contract
A complete voting contract you can deploy
This pulls together every concept above into one deployable file. It uses two structs, two mappings, packed fields, and a small parallel array for enumeration.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract Ballot {
struct Voter {
uint96 weight;
bool voted;
uint8 choice;
address delegate;
}
struct Proposal {
bytes32 name;
uint128 voteCount;
uint64 endsAt;
}
address public immutable chair;
mapping(address => Voter) public voters;
Proposal[] public proposals;
address[] private registered; // parallel list for enumeration
event Registered(address indexed voter, uint96 weight);
event Voted(address indexed voter, uint8 indexed proposalId, uint96 weight);
modifier onlyChair() {
require(msg.sender == chair, "not chair");
_;
}
constructor(bytes32[] memory names, uint64 endsAt) {
chair = msg.sender;
for (uint256 i; i < names.length; ++i) {
proposals.push(Proposal({ name: names[i], voteCount: 0, endsAt: endsAt }));
}
}
function giveRightToVote(address voter, uint96 weight_) external onlyChair {
Voter storage v = voters[voter];
require(!v.voted, "already voted");
require(v.weight == 0, "already registered");
v.weight = weight_;
registered.push(voter);
emit Registered(voter, weight_);
}
function vote(uint8 proposalId) external {
Voter storage v = voters[msg.sender];
require(v.weight != 0, "no right to vote");
require(!v.voted, "already voted");
require(proposalId < proposals.length, "bad proposal");
require(block.timestamp <= proposals[proposalId].endsAt, "voting closed");
v.voted = true;
v.choice = proposalId;
proposals[proposalId].voteCount += v.weight;
emit Voted(msg.sender, proposalId, v.weight);
}
function voterCount() external view returns (uint256) {
return registered.length;
}
}The contract is intentionally minimal. A real protocol would add delegation, quorum requirements, time locked execution, and an event for delegation chains. Each of those is one more field on the Voter or Proposal struct, which is exactly the point: the mapping with struct shape grows naturally with the protocol.
To find the winning proposal you would loop over the proposals array and compare vote counts. That iteration pattern, including how to keep it safe as the array grows, is the subject of the companion piece on Solidity arrays with loops.
FAQ
Frequently asked questions
Can a Solidity struct contain a mapping?
Yes, but only when the struct lives in storage. A storage struct can hold a mapping field, and you read and write it through the struct reference. A memory struct cannot hold a mapping at all because mappings only exist in storage. Public getters cannot serialize structs that contain mappings, so you must write an explicit getter for the mapping field.
How do I delete an entry from a mapping with struct?
Use delete name[key]. This resets every field of the struct to its default value: zero for numbers, false for bools, the zero address for addresses. It does not return gas equal to the original write and it does not remove anything from a parallel enumeration array. Update both data structures together to keep them consistent.
Is mapping with struct cheaper than separate mappings for each field?
On reads they are similar — the EVM does one storage read either way per field. On writes the struct version wins when fields pack together into the same slot, because one packed write replaces several separate slot writes. If your fields are all uint256, the two approaches cost the same.
How many fields should a Solidity struct have?
Typically three to seven. Below three you might as well use a flat mapping. Above seven the struct usually represents two concepts that should be split. Long structs also make storage upgrades fragile because reordering one field shifts every later slot. Bias toward smaller, more focused structs.
Can I iterate every entry in a mapping of struct?
Not directly. Mappings have no internal list of keys, so there is nothing to iterate. The standard workaround is to maintain a parallel array of keys and update it whenever you add or remove an entry. For large datasets, keep the iteration off chain — read the array via eth_call and process the records in your backend.
Should I use a struct or a nested mapping for two key data?
Use a nested mapping when each entry is a single value, like ERC20 allowances. Use a mapping to a struct that contains a mapping when the outer key has its own metadata beyond the inner lookup, like a vault holder with both global state and per strategy share balances. The struct form makes the metadata accessible without an extra storage slot pattern.