JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

Types, mappings, and structs: modeling data on-chain

Every byte a contract stores costs real money and lives forever. Learn Solidity's type system — value types, reference types with the all-important data-location rule, and the mapping — and build a struct-and-mapping registry that lays out cleanly in storage.

A contract is a database you rent forever

In the previous guide you wrote a contract with a couple of state variables — a number, an address. Real contracts model far richer data: a token's balance for every holder, an NFT's owner for every token id, a DAO's table of proposals. Picture a tiny database that lives inside a smart contract — except this database is replicated across thousands of nodes, every byte you write is paid for in gas, and what you store persists forever. That economic weight is exactly why Solidity's type system is stricter than the languages you may know: it wants you to say precisely how big a value is and where it lives.

This guide covers that type system in three layers. First the value types — the 256-bit building blocks copied whenever you touch them. Then the reference types (arrays, structs, strings) and the rule Solidity forces on you: every reference must be tagged `storage`, `memory`, or `calldata`. Then the workhorse of on-chain data, the mapping. We finish by laying out a real registry in storage slot by slot so you can see — and pay less for — exactly what you built.

Value types: the 256-bit building blocks

The EVM's native word is 256 bits wide, so Solidity's number type of choice is `uint256` (unsigned, 0 to 2²⁵⁶−1). You can ask for narrower sizes in 8-bit steps — `uint8`, `uint16`, … `uint256` — and signed versions with `int`. A value type is copied whenever you assign it or pass it to a function, so the original is never disturbed. The other value types you will reach for constantly are `address` (a 20-byte account), `bool`, the fixed byte blobs `bytes1`…`bytes32` (a hash is a natural `bytes32`), and `enum`, which the compiler stores as a small integer under the hood.

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

contract ValueTypes {
    uint256 public count;        // unsigned, 0 .. 2^256-1
    int256  public temperature;  // signed
    uint8   public smallFlag;    // 0 .. 255
    address public owner;        // a 20-byte account address
    bool    public paused;       // true / false
    bytes32 public root;         // a fixed 32-byte blob, e.g. a hash

    enum Status { Pending, Active, Closed }
    Status public status;        // stored as a uint8 under the hood

    function tick() external {
        count += 1;              // since 0.8, this REVERTS on overflow
    }
}
The everyday value types. Note `enum` members are just 0, 1, 2…

One thing changed forever in Solidity 0.8: arithmetic is checked by default. Before 0.8, `count += 1` past the maximum would silently wrap around to zero (integer overflow) — a bug that has drained real contracts. Now it reverts the whole transaction instead. A subtle point for later: declaring `uint8` instead of `uint256` does not save gas on its own, because a lone variable still occupies a full 32-byte slot. The saving only appears when several small fields pack into one slot together — which is where structs come in.

Reference types and the data-location rule

Arrays, structs, `bytes`, and `string` are reference types. They can be arbitrarily large, so they don't sit on the EVM's tiny stack — they live somewhere, and you pass around a reference to that place. Solidity refuses to guess where: every reference-typed variable must be tagged with a data location. There are exactly three, and choosing right is half of writing correct, cheap Solidity.

  1. storage — the contract's permanent state, written to the EVM storage that persists between transactions. The most expensive place to touch. A `storage` local is an alias (a pointer) into existing state, not a copy.
  2. memory — temporary scratch space, allocated fresh for the duration of a call and wiped clean when the function returns. Cheaper than storage; use it for working copies and values you build up and return.
  3. calldata — the read-only region holding a transaction's input. It can't be modified, never gets copied, and is the cheapest of all. Use it for the array and string parameters of `external` functions.
struct Point { uint256 x; uint256 y; }

contract Locations {
    Point[] public points;                 // lives in storage (persistent)

    // calldata: read-only input, never copied, cheapest
    function sum(uint256[] calldata xs) external pure returns (uint256 s) {
        for (uint256 i; i < xs.length; ++i) s += xs[i];
    }

    // memory: a temporary copy, gone when the call ends
    function makePoint(uint256 x, uint256 y) external pure returns (Point memory) {
        return Point({x: x, y: y});
    }

    // storage reference: a POINTER into points[i]; writes persist
    function bumpX(uint256 i) external {
        Point storage p = points[i];        // alias, not a copy
        p.x += 1;                           // mutates storage directly
    }
}
The same three locations in one contract.

The single most common beginner bug hides in that distinction. `Point storage p = points[i]` makes `p` an alias — writing through it changes the stored point. But `Point memory p = points[i]` first copies the struct out of storage into memory; any change you make is to the throwaway copy and is silently discarded when the function ends. The compiler won't stop you; the chain just quietly does nothing.

// BUG: `Point memory p` copies the struct; the write is thrown away.
function brokenBumpX(uint256 i) external {
    Point memory p = points[i];  // COPY of storage into memory
    p.x += 1;                    // mutates the copy only
}                                // storage unchanged -> silent no-op
memory vs storage: a one-word change turns a write into a no-op.

Mappings: the on-chain hash table

The mapping is the single most-used data structure on Ethereum. `mapping(KeyType => ValueType)` behaves like a hash table — `balances[alice]` reads and `balances[alice] = 10` writes — but with three rules that surprise newcomers. First, every possible key already exists, returning the zero value of its type until you set it. Second, you cannot iterate a mapping or ask its length; it has no concept of which keys were used. Third, it can only live in `storage` and cannot be returned wholesale or held in `memory`.

// the classic balance ledger
mapping(address => uint256) public balances;

function deposit() external payable {
    balances[msg.sender] += msg.value;   // a missing key reads as 0, so += just works
}

// nested mapping: owner => spender => amount (the ERC-20 allowance pattern)
mapping(address => mapping(address => uint256)) public allowance;
A balance ledger and a nested allowance mapping.

Why can't you iterate? Because a mapping doesn't store its keys at all. If the mapping is declared at slot `p`, the value for key `k` lives at storage slot `keccak256(abi.encode(k, p))` — a deterministic but scattered location. There is no list of keys to walk. This is also why a mapping costs nothing to declare regardless of how many keys it will hold: nothing is allocated up front; a storage slot is only written the first time you store a non-zero value for a key.

Structs: bundling fields into a record

A struct groups related fields into one named record type. Pair it with a `mapping(address => Member)` and you have a per-user store — a member's name, tier, and join time all hanging off their address. This struct-plus-mapping shape is the backbone of an enormous fraction of real contracts. Here is a small registry that puts everything in this guide together.

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

contract MemberRegistry {
    struct Member {
        string  name;      // dynamic -> gets its own slot(s)
        uint64  joinedAt;  // unix time; packs with `tier` and `active`
        uint32  tier;
        bool    active;
    }

    mapping(address => Member) private members;
    uint256 public total;

    function register(string calldata name, uint32 tier) external {
        Member storage m = members[msg.sender];   // storage reference
        require(m.joinedAt == 0, "already registered"); // sentinel check
        m.name     = name;
        m.joinedAt = uint64(block.timestamp);
        m.tier     = tier;
        m.active   = true;
        total += 1;
    }

    function deactivate() external {
        members[msg.sender].active = false;        // one-field write, no copy
    }

    function memberOf(address who)
        external
        view
        returns (string memory name, uint64 joinedAt, uint32 tier, bool active)
    {
        Member storage m = members[who];
        return (m.name, m.joinedAt, m.tier, m.active);
    }
}
A registry: a struct held in a mapping, edited through storage references.

Read it slowly. `register` takes `name` as `calldata` (cheapest input), grabs a `storage` reference `m`, and uses `m.joinedAt == 0` as a sentinel so no address can register twice. `deactivate` writes a single field directly — `members[msg.sender].active = false` — never copying the record. `memberOf` returns the fields as a tuple; note it can return the struct's contents, but a function can never return a struct that itself contains a mapping, because mappings can't be copied into memory.

How it's laid out in storage — and why packing saves money

A contract's storage is conceptually 2²⁵⁶ slots of 32 bytes each, all starting at zero. The compiler assigns layout deterministically: plain state variables get slots 0, 1, 2… in declaration order, and several small fields that fit are packed side by side into one slot. Dynamic things — mappings and dynamic arrays — store only a placeholder at their declared slot and scatter their actual data to hashed locations, so they never collide with the fixed variables.

Look back at the `Member` struct. Its `name` is a dynamic `string`, so it takes a slot of its own (holding length plus a pointer to the character data). The next three fields — `uint64 joinedAt` (8 bytes), `uint32 tier` (4 bytes), `bool active` (1 byte) — total 13 bytes, comfortably under 32, so the compiler packs all three into a single 32-byte slot. That is not a cosmetic detail: it means the whole `joinedAt`/`tier`/`active` group can be written with one storage write instead of three.

Why obsess over one write? Because writing storage is, by a wide margin, the most expensive thing a contract does. As a rough guide: setting a slot from zero to non-zero costs about 20,000 gas (plus a ~2,100 first-touch surcharge), changing an already-non-zero slot about 5,000 gas, while merely reading a slot is ~2,100 gas cold and ~100 gas if you've touched it this transaction. Computation by comparison is almost free — an `ADD` is 3 gas. So gas optimization in Solidity is overwhelmingly about touching fewer storage slots, and field packing is the cheapest way to do it.

Common traps and a mental model to carry forward

These types are now your vocabulary for modeling anything on-chain. Before moving on, internalize the traps that catch even experienced developers — almost all of them come from forgetting the difference between copying a value and referencing state.

  1. memory vs storage on locals. `Foo memory f = items[i]` copies and your writes vanish; `Foo storage f = items[i]` aliases and your writes stick. When in doubt about whether a change persisted, this is the first thing to check.
  2. Default-zero ambiguity. A mapping value of 0 might mean "unset" or "genuinely zero". Use an explicit sentinel field (like `joinedAt`) or a separate `mapping(... => bool) exists` when the distinction matters.
  3. Mappings don't enumerate. No length, no loop, no `keys()`. If you need a list, maintain a parallel `address[]` yourself — and remember that array can grow unboundedly and make functions that loop over it run out of gas.
  4. `delete` resets, it doesn't remove. `delete members[addr]` resets that struct's fields to their zero values; it can't actually erase a key from a mapping, and it can't clear a mapping nested inside a struct.

Hold onto one mental model: a value type is a thing you carry, a reference type is a place you point at, and the data-location tag says whether that place is permanent (storage), scratch (`memory`), or the incoming message (`calldata`). With data modeling in hand, the next guide adds the guard rails — Solidity modifiers, `require`, custom errors, and events — that decide who may change this state and how the outside world hears about it.