BlockchainSolidity14 min readUpdated

Solidity Mapping with Struct: Patterns and Examples

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

Cover illustration for: Solidity Mapping with Struct: Patterns and Examples

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.

solidity
// 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.

Diagram showing how a Solidity mapping of address to a Voter struct lays out its fields across storage slots, with one slot per packed struct member.
Each key resolves to a base slot via keccak256. The struct's fields then occupy as many consecutive slots as they need.

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.

Flow diagram of a Solidity voting contract: register a voter, delegate voting weight, cast a vote, and update the proposal struct counters.
Two mappings, two structs. Voter holds per address state. Proposal aggregates totals across voters.

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:

solidity
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.

solidity
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.

Comparison of two Solidity struct field orderings, showing that the packed version uses one storage slot while the unpacked version uses three.
Same fields, different declaration order. The packed layout costs around 22k gas to write a fresh voter; the unpacked layout costs around 63k.

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.

solidity
// 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.

solidity
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.

solidity
// 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.

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 →