Why struct packing matters

Solidity stores state variables in 32-byte storage slots. Smaller values such as uint8, bool, and address do not always consume a full slot on their own. If several compatible values fit into one slot, Solidity can pack them together.

For example, a struct with three small fields may fit into one slot instead of three. That reduces:

  • storage footprint
  • gas cost for deployment
  • gas cost for writes in some cases
  • the number of storage slots touched during reads

This is especially useful in contracts that store many records, such as:

  • user profiles
  • order books
  • voting records
  • configuration objects
  • on-chain game state

The key idea is simple: place smaller fields together so Solidity can pack them into the same 32-byte slot.


How Solidity packs struct fields

Solidity packs storage variables and struct members in declaration order. It starts filling a 32-byte slot from right to left, placing smaller values together when possible.

Packing rules to remember

  • Values are packed only if they fit in the same 32-byte slot.
  • A value that does not fit starts a new slot.
  • Types larger than 16 bytes, such as uint256, bytes32, and address?
  • address is 20 bytes, so it can pack with smaller values if space remains.
  • Struct members are packed in the order they are declared.
  • Dynamic types like string, bytes, arrays, and mappings do not pack like fixed-size values.

Example of efficient packing

struct Position {
    uint128 amount;
    uint64 openedAt;
    uint32 feeBps;
    bool active;
}

These fields total 128 + 64 + 32 + 8 = 232 bits, which fits within one 256-bit storage slot. That means one slot can hold the entire struct.

Now compare that to a less efficient layout:

struct Position {
    uint256 amount;
    uint64 openedAt;
    uint32 feeBps;
    bool active;
}

Here, uint256 amount consumes a full slot by itself. The remaining fields may pack together, but the struct now uses more storage overall.


A practical example: user profile storage

Suppose you are building a simple membership contract that stores profile data for each account.

Poorly packed version

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

contract ProfilesBad {
    struct Profile {
        uint256 reputation;
        bool verified;
        uint64 joinedAt;
        address referrer;
    }

    mapping(address => Profile) public profiles;

    function createProfile(address user, address referrer) external {
        profiles[user] = Profile({
            reputation: 0,
            verified: false,
            joinedAt: uint64(block.timestamp),
            referrer: referrer
        });
    }
}

At first glance, this looks fine. But uint256 reputation takes a full slot, and address referrer is 20 bytes, so the struct is spread across multiple slots.

Packed version

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

contract ProfilesPacked {
    struct Profile {
        address referrer;
        uint64 joinedAt;
        uint32 reputation;
        bool verified;
    }

    mapping(address => Profile) public profiles;

    function createProfile(address user, address referrer) external {
        profiles[user] = Profile({
            referrer: referrer,
            joinedAt: uint64(block.timestamp),
            reputation: 0,
            verified: false
        });
    }
}

This version is more storage-efficient because the fields are arranged to fit better together. The address uses 20 bytes, and the smaller numeric fields can share the remaining space in the slot.

Why this helps

If your contract creates thousands of profiles, saving even one storage slot per profile can have a meaningful effect. Storage is one of the most expensive resources on-chain, so reducing slot usage is often worth the effort.


When packing helps most

Packing is not equally valuable in every situation. It is most useful when:

  • you store many instances of the same struct
  • the struct is written frequently
  • the struct contains several small fields
  • the contract is expected to live long enough for storage costs to matter

It is less important when:

  • the struct is used only once or a few times
  • the contract is read-heavy but rarely written
  • the data is mostly dynamic or large already
  • readability would suffer from aggressive type shrinking

Good candidates for packing

Use caseWhy packing helps
User recordsMany repeated entries in a mapping
Orders and positionsSmall status flags and timestamps often fit together
Governance snapshotsMultiple counters and booleans can share slots
Game entitiesHealth, level, and state flags are often compact

Choosing the right types

Packing is not just about ordering fields. It also depends on selecting the smallest type that safely represents the data.

Prefer the smallest safe type

Use the smallest integer type that matches the expected range:

  • uint64 for timestamps
  • uint32 for counters that will never exceed 4 billion
  • uint16 for small configuration values
  • bool for binary flags

Avoid using uint256 by default unless the value truly needs that range.

Example: timestamp and counters

struct Auction {
    address seller;
    uint64 startTime;
    uint64 endTime;
    uint32 bidCount;
    bool finalized;
}

This layout is compact and practical. Timestamps fit comfortably in uint64, and bidCount is often far below uint32 limits.

Be careful with over-optimization

Do not shrink types blindly. If a value can grow beyond the chosen type, you risk overflow or future migration pain. For example:

  • use uint64 for timestamps, not uint32
  • use uint128 for token amounts if the domain is well bounded
  • keep uint256 for balances and generic arithmetic unless you have a strong reason not to

A good rule is: optimize the layout, but do not compromise correctness.


Field order matters

The order of struct members affects packing. Solidity does not rearrange fields automatically.

Less efficient ordering

struct Config {
    uint256 maxSupply;
    bool paused;
    uint64 epoch;
    uint32 feeBps;
}

Here, uint256 maxSupply consumes one full slot, and the smaller fields may or may not pack efficiently depending on alignment.

Better ordering

struct Config {
    uint64 epoch;
    uint32 feeBps;
    bool paused;
    uint256 maxSupply;
}

This still uses a full slot for uint256 maxSupply, but the smaller fields are grouped together first. In many cases, that reduces wasted space and makes the layout cleaner.

Practical ordering guideline

A common approach is:

  1. group small fields together
  2. place larger fields after them
  3. keep related fields near each other for readability

This balances gas efficiency and maintainability.


Packing trade-offs: reads, writes, and complexity

Packing is not free in every sense. It can reduce storage usage, but it may also introduce subtle trade-offs.

Packed writes may require masking

When multiple values share a slot, updating one field can require reading the existing slot, modifying only the relevant bits, and writing the slot back. Solidity handles this automatically, but the operation is more complex than writing a full standalone slot.

This means:

  • packed storage is great when you want to reduce slot count
  • unpacked storage can sometimes be simpler for frequent independent updates

Example trade-off

If a struct contains a frequently updated uint256 balance and rarely changed flags, forcing them into the same slot may not be ideal. In that case, keeping the balance separate can make the code clearer and may avoid unnecessary bit manipulation.

Rule of thumb

Use packing when:

  • the fields are naturally small
  • the struct is stored many times
  • the storage savings are meaningful

Avoid packing when:

  • the layout becomes confusing
  • the fields are updated independently and frequently
  • the type choices become unnatural

Comparing common layouts

Layout styleStorage efficiencyReadabilityBest for
All uint256 fieldsLowHighSimple logic, low concern for gas
Mixed small fields without orderingMediumMediumQuick prototypes
Deliberately packed structHighMediumProduction contracts with repeated records
Over-packed with unnatural typesHighLowRarely worth it

The best design is usually not the most compact possible one. It is the one that saves meaningful gas while remaining easy to audit and maintain.


Best practices for packed structs

1. Design the struct around real data ranges

Start by identifying the actual range of each field. For example:

  • timestamps: uint64
  • status flags: bool
  • counts: uint32 or uint64
  • addresses: address

This gives you a principled basis for packing.

2. Group fields by size

Put fields of similar size together so they can share a slot more easily.

3. Document the intent

When a struct is packed intentionally, add a short comment explaining why the layout looks the way it does. This helps future maintainers avoid accidental refactors that increase gas costs.

struct Order {
    // Packed to fit into one slot where possible.
    address maker;
    uint64 createdAt;
    uint32 amount;
    bool filled;
}

4. Test layout-sensitive code carefully

If your contract depends on storage layout, especially in upgradeable systems, changes to struct order or type sizes can break compatibility. Treat storage layout as part of the contract’s public interface.

5. Measure before and after

Use gas reporting tools and compare:

  • deployment cost
  • write cost for creating or updating records
  • read cost for common access paths

Packing is only worthwhile if the savings are real in your workload.


A simple decision checklist

Before changing a struct for packing, ask:

  • Are these fields stored many times?
  • Can smaller types safely represent the values?
  • Will the new order improve slot usage?
  • Does the layout remain understandable?
  • Could this affect upgrade compatibility?

If the answer is yes to most of these, packing is likely a good optimization.


Conclusion

Struct packing is one of the most practical storage optimizations in Solidity. By choosing appropriately sized types and ordering fields carefully, you can reduce storage usage and lower gas costs without changing contract behavior.

The best results come from a disciplined approach:

  • use the smallest safe types
  • group compatible fields together
  • keep the layout readable
  • measure the impact on real contract flows

For contracts that store many repeated records, these savings can add up quickly.

Learn more with useful resources