Solidity Storage Packing


TL;DR

  • Every contract gets a key-value store of 2²⁵⁶ 32-byte slots.
  • Small types (bool, uint8/16/…, address) bool, pack into the same slot from the right (low-order) bytes in declaration order until there’s no room.
  • Big types (uint256, bytes32) always take a full slot. Variables never straddle slots.
  • You pay gas per slot, not per byte. Bad ordering = more slots touched = more gas.
  • This visual interactive demo helps prove the concept I’m writing about: Solidity Storage Packing Lab.

Writing about Solidity

I’m deep-diving Solidity from the ground up to sharpen my understanding and share practical takeaways. This article collects my notes and examples, starting where it all begins: storage slots. Understanding how data lives in 32-byte words explains packing rules and gas costs. Once storage clicks, choosing the right types and using them correctly becomes an easy win for clarity and gas.

The mental model in 90 seconds

  • Think of contract storage as a massive map: slot index -> 32-byte word.
  • Unwritten slots read as zero.
  • When a value uses fewer than 32 bytes, Solidity places it in the lowest-order bytes of its slot (the far right of a 32-byte hex string).
  • Packing happens in declaration order. If the next field doesn’t fit entirely, Solidity starts a new slot (no spilling).
  • Mappings/dynamic arrays don’t pack with neighbors; they reserve an anchor slot, and actual elements live at hashed locations.

Minimal lab: prove that bool true is 01 in slot 0

Contract (tiny on purpose) use Hardhat if you want


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

contract BoolCases {
    bool public flag;
    function setFlag(bool v) external { flag = v; }
}

Test (Hardhat 3 + node:test + viem)

import test from "node:test";
import assert from "node:assert/strict";
import { network } from "hardhat";
import type { Address, Hex } from "viem";

test("BoolCases: true is ...01 in slot 0", async () => {
  const { viem } = await network.connect();
  const c = await viem.deployContract("BoolCases", []);

  await c.write.setFlag([true]);

  const client = await viem.getPublicClient();
  const slot0 = await client.getStorageAt({
    address: c.address as Address,
    slot: "0x0" as Hex
  });

  assert.equal(typeof slot0, "string");
  assert.ok(slot0!.toLowerCase().endsWith("01"), \`slot0=\${slot0}\`);
});

Run it

npx hardhat test test/bool.node.ts

What you’ll see

  • 0x0000…0001 when flag == true
  • 0x0000…0000 when flag == false

That final byte is the whole show.


Pocket rules you can trust

  1. Group small types together. Put bool , uint8/16/32/64/96/128, address adjacent so they share one slot.

    Example: bool a; uint8 b; bool c; -> one slot (last three bytes flip).
  2. Keep full-slot types separate. uint256, bytes32 take a whole slot. If placed between smalls, they break the packing run.

    Example: bool a; uint256 x; bool b; -> three slots (a in slot0, x in slot1, b in slot2).
  3. Variables never straddle slots. bool; uint256; -> the uint256 goes entirely to slot 1 (31 bytes left in slot 0 are just unused).
  4. Mappings/arrays are boundaries. A mapping claims an anchor slot; each key’s value is stored at (keccak256(pad32(key) || pad32(anchorSlot)). Don’t count on packing across it.
  5. Flags? Use a bitmap. One uint256 gives you 256 toggles via bit ops often cleaner/cheaper than many bools.
  6. Guard writes. if(x!=v) x=v; can save the SSTORE when nothing changes. You pay per slot write, not per byte.

A couple of guided examples

uin256 then bool

uint256 x; //slot 0 (all 32 bytes)
bool    b; // slot 1 (lowest byte)

Slot 0: x
Slot 1: ends with …00 (false) or …01 (true)

Small-type cluster, then bigs

bool paused;
uint8 decimals;
address owner;    // 20 bytes
uint96 cap;        // 12B
uint256 totalSupply;
bytes32  domainSeparator; //full slot

This touches fewer slots than interleaving bigs between the smalls.

FAQ (the bits people trip on)

“Isn’t the EVM little-endian?”
Storage layout for Solidity values is easiest to remember as right-aligned within the 32-byte word. Look at the rightmost bytes for small values.

“Can I pack across mappings?”
No. A mapping’s anchor occupies a slot; elements live at hashed slots. Treat it as a boundary.

“Why do I see gas used when writing the same value?”
Even a same-value SSTORE can cost because the slot is touched. Guard writes in hot paths.

What’s next in the series

  • Packing rules with small types (prove a few combinations)
  • Mappings (compute hashed slots and read them)
  • Dynamic arrays, bytes, string (short/long storage difference)
  • Upgrade safety (why reordering fields bricks proxies)

conclusion

Start with storage; everything else builds on it. Once you can see bytes packing from the right, picking types and avoiding extra slots becomes cheaper, and second nature. But remember: the EVM itself doesn’t know about your variables and it only stores raw 32-byte slots. Understanding that distinction is the next step: Ethereum doesn’t care about your variables.

By Burt Snyder

Writing about interesting things and sharing ideas.

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.