There are two common ways to store grouped data in Solidity: a mapping to a struct, or a mapping to a single bytes32 with the fields packed in. I wrote both, tested them with Foundry, and checked the gas numbers. The packed version (one storage slot instead of four) is meaningfully cheaper to write. Below is the code, the tests, the results, and what I think .
Code Overview
I built two contracts that store the same TokenSale record, four fields, but lay it out differently. One uses a struct; the other packs everything into a single bytes32. The trick generalizes to any struct-like record.
Shared components
Both contracts share the same struct definition and encoding library.
TokenSale struct and bit-field layout
| Field | Bits | Size | Purpose |
|---|---|---|---|
seller | 0–159 | 160 bits (20 bytes) | address |
tokenID | 160–191 | 32 bits | Token identifier |
price | 192–223 | 32 bits | Sale price |
isActive | 224 | 1 bit | Status flag |
The 256-bit budget: a bytes32 slot gives us 256 bits to work with. This example uses 225 bits (seller 160 + tokenID 32 + price 32 + isActive 1), leaving 31 bits free. What we can pack in depends entirely on the shape of your data: 256 booleans, or 32 small ints, or 8 larger ones, or any mix that adds up. The catch is we have to size each field deliberately. Here, tokenID and price are squeezed into 32 bits each, which caps them at 4,294,967,295. That’s fine for some use cases and a dealbreaker for others.
// Shared.sol
pragma solidity ^0.8.22;
struct TokenSale {
address seller;
uint256 tokenID;
uint256 price;
bool isActive;
}
uint256 constant TOKEN_SALE_SELLER_SHIFT = 0;
uint256 constant TOKEN_SALE_TOKEN_ID_SHIFT = 160;
uint256 constant TOKEN_SALE_PRICE_SHIFT = 192;
uint256 constant TOKEN_SALE_IS_ACTIVE_SHIFT = 224;
error TokenSaleInvalidValue();
library TokenSaleEncoding {
// pack a full struct into one bytes32
function encode(TokenSale memory tokenSale)
internal
pure
returns (bytes32 data)
{
// Safety checkpoint: ensure values fit in 32 bits to prevent silent overflow
if (tokenSale.tokenID > type(uint32).max || tokenSale.price > type(uint32).max) {
revert TokenSaleInvalidValue();
}
data =
bytes32(uint256(uint160(tokenSale.seller))) |
(bytes32(tokenSale.tokenID) << TOKEN_SALE_TOKEN_ID_SHIFT) |
(bytes32(tokenSale.price) << TOKEN_SALE_PRICE_SHIFT) |
(bytes32(uint256(tokenSale.isActive ? 1 : 0)) << TOKEN_SALE_IS_ACTIVE_SHIFT);
return data;
}
// pack raw fields into one bytes32
function encode(
address _seller,
uint256 _tokenID,
uint256 _price,
bool _isActive
) internal pure returns (bytes32 data) {
if (_tokenID > type(uint32).max || _price > type(uint32).max) {
revert TokenSaleInvalidValue();
}
data =
bytes32(uint256(uint160(_seller))) |
(bytes32(_tokenID) << TOKEN_SALE_TOKEN_ID_SHIFT) |
(bytes32(_price) << TOKEN_SALE_PRICE_SHIFT) |
(bytes32(uint256(_isActive ? 1 : 0)) << TOKEN_SALE_IS_ACTIVE_SHIFT);
return data;
}
// unpack a bytes32 into a struct in memory
function decode(bytes32 data)
internal
pure
returns (TokenSale memory tokenSale)
{
tokenSale.seller = address(uint160(uint256(data)));
tokenSale.tokenID = uint256(uint32(uint256(data >> TOKEN_SALE_TOKEN_ID_SHIFT)));
tokenSale.price = uint256(uint32(uint256(data >> TOKEN_SALE_PRICE_SHIFT)));
tokenSale.isActive =
(uint256(data >> TOKEN_SALE_IS_ACTIVE_SHIFT) & 1) == 1;
return tokenSale;
}
// read only the price field from the packed word
function decodePrice(bytes32 data)
internal
pure
returns (uint256 price)
{
price = uint256(uint32(uint256(data >> TOKEN_SALE_PRICE_SHIFT)));
return price;
}
// encode only the price field into the correct bit position
function encodePrice(uint256 price) internal pure returns (bytes32) {
if (price > type(uint32).max) {
revert TokenSaleInvalidValue();
}
return bytes32(price) << TOKEN_SALE_PRICE_SHIFT;
}
}
Contract 1: Struct-based storage (baseline)
Lets the compiler handle slot allocation
// StructBasedRegistry.sol
pragma solidity ^0.8.22;
import {TokenSale} from "./Shared.sol";
contract StructBasedRegistry {
// struct representation: struct spread over multiple slots
mapping(address => TokenSale) public tokenSalesStruct;
// store the struct directly in a mapping
function createTokenSaleInfo(
address _seller,
uint256 _tokenID,
uint256 _price,
bool _isActive
) external {
TokenSale memory tokenSale = TokenSale({
seller: _seller,
tokenID: _tokenID,
price: _price,
isActive: _isActive
});
tokenSalesStruct[msg.sender] = tokenSale;
}
}
Storage layout: The TokenSale struct occupies 4 storage slots per entry with the current field ordering:
- Slot 0:
address seller(20 bytes) - occupies one slot with 12 bytes unused - Slot 1:
uint256 tokenID= 1 full slot - Slot 2:
uint256 price= 1 full slot - Slot 3:
bool isActive(1 byte) - occupies one slot with 31 bytes unused
A note on packing: Solidity only packs adjacent variables into the same 32-byte slot. Here, the uint256 fields sit between the address and the bool, so the compiler can’t pack anything, and every field gets its own slot. Reordering it (move the bool up next to the address) would knock it down to three slots. I left the bad ordering in on purpose: it’s the worst case, and it makes the win from manual packing easier to see.
Contract 2: Encoded storage (optimized)
This contract manually packs all fields into a single bytes32, using only 1 storage slot per entry.
// EncodedRegistry.sol
pragma solidity ^0.8.22;
import {TokenSale, TokenSaleEncoding} from "./Shared.sol";
contract EncodedRegistry {
using TokenSaleEncoding for TokenSale;
// encoded representation: one bytes32 per address
mapping(address => bytes32) public tokenSalesData;
// store packed data in a single storage slot
function createTokenSaleWithEncode(
address _seller,
uint256 _tokenID,
uint256 _price,
bool _isActive
) external {
tokenSalesData[msg.sender] =
TokenSaleEncoding.encode(_seller, _tokenID, _price, _isActive);
}
// obtain an encoded record without touching storage
function getTokenSaleEncoded(
address _seller,
uint256 _tokenID,
uint256 _price,
bool _isActive
) external pure returns (bytes32 tokenSale) {
tokenSale = TokenSaleEncoding.encode(_seller, _tokenID, _price, _isActive);
}
// set the encoded record directly
function setTokenSaleEncode(bytes32 _tokenSale) external {
tokenSalesData[msg.sender] = _tokenSale;
}
// update only the price field using bit operations
function changePrice(address _seller, uint256 _price) external {
bytes32 currentEncode = tokenSalesData[msg.sender];
uint256 currentTokenID =
uint256(uint32(uint256(currentEncode >> TOKEN_SALE_TOKEN_ID_SHIFT)));
uint256 currentIsActive =
(uint256(currentEncode >> TOKEN_SALE_IS_ACTIVE_SHIFT) & 1);
bytes32 newEncode = TokenSaleEncoding.encode(
_seller,
currentTokenID,
_price,
currentIsActive == 1
);
tokenSalesData[msg.sender] = newEncode;
}
// decode a packed word into a struct in memory
function getTokenSaleWithDecode(bytes32 data)
external
pure
returns (TokenSale memory tokenSale)
{
tokenSale = TokenSaleEncoding.decode(data);
}
}
Storage layout: Each entry uses 1 storage slot (bytes32) with manual bit-packing:
- Bits 0–159:
address seller - Bits 160–191:
uint256 tokenID(truncated to 32 bits) - Bits 192–223:
uint256 price(truncated to 32 bits) - Bit 224:
bool isActive(1 bit)
Test contract
The Foundry test file deploys both contracts and calls their functions to compare gas usage.
// TokenSaleTest.t.sol
import {Test, console} from "forge-std/Test.sol";
import {StructBasedRegistry} from "../src/StructBasedRegistry.sol";
import {EncodedRegistry} from "../src/EncodedRegistry.sol";
contract TokenSaleTest is Test {
uint256 public tokenID;
uint256 public price;
address public seller;
bool public isActive;
StructBasedRegistry public structRegistry;
EncodedRegistry public encodedRegistry;
function setUp() public {
structRegistry = new StructBasedRegistry();
encodedRegistry = new EncodedRegistry();
tokenID = 1;
price = 100;
seller = address(0x123);
isActive = true;
}
function test_createTokenSaleInfo() public {
structRegistry.createTokenSaleInfo(
seller,
tokenID,
price,
isActive
);
}
function test_createTokenSaleWithEncode() public {
encodedRegistry.createTokenSaleWithEncode(
seller,
tokenID,
price,
isActive
);
}
}
The Core Idea
Two contracts, same data, different storage layouts:
StructBasedRegistry uses Solidity’s built in struct storage. With the current field order, the compiler spreads the TokenSale struct across 4 slots. Slot count depends on field types and order: small types side by side can pack together, but here the bool is stranded behind two uint256 fields, so no packing happens.
EncodedRegistry packs all four fields into a single bytes32 with bit shifts. One slot per record, fewer SSTOREs per write, less gas. The same approach works for any set of fields that fit in 256 bits.
SSTORE is expensive, so collapsing four slots into one is a real win, not a micro optimization.
How the Layouts Work
StructBasedRegistry
The TokenSale struct lands in 4 slots with this field order. The address (20 bytes) sits alone in the first slot, each uint256 takes its own slot, and the bool (1 byte) gets the fourth. Order matters: move the bool up next to the address and they share a slot, dropping the total to 3. I’m sticking with the unpacked layout because it sets the ceiling for what manual packing can save. The Solidity compiler decides all of this based on declaration order.
EncodedRegistry
The encoded version packs all four fields into one bytes32 by hand. TOKEN_SALE_*_SHIFT constants mark off fixed bit ranges, and TokenSaleEncoding.encode combines the fields with bitwise OR. The result: exactly one bytes32 per address key.
Test configuration
The test contract deploys both StructBasedRegistry and EncodedRegistry with the same parameters:
| Variable | Value |
|---|---|
| tokenID | 1 |
| price | 100 |
| seller | 0x123 |
| isActive | true |
It then executes two test functions:
- test_createTokenSaleInfo: calls the struct based creation function in
StructBasedRegistry. - test_createTokenSaleWithEncode: Calls the encoded creation function in
EncodedRegistry.
Foundry’s gas reporter logs the gas used by each test and the functions underneath.
Key Findings
The gas report has a summary table for the tests and a detailed table for the functions.
Test-level gas costs
| Test | Gas cost |
|---|---|
test_createTokenSaleInfo | 123,633 |
test_createTokenSaleWithEncode | 57,256 |
Same inputs, same conditions, the encoded path uses 66,377 fewer gas units than the struct path.
Function-level gas costs
| Function name | Min | Avg | Median | Max | # calls |
|---|---|---|---|---|---|
createTokenSaleInfo | 111,396 | 111,396 | 111,396 | 111,396 | 1 |
createTokenSaleWithEncode | 45,041 | 45,041 | 45,041 | 45,041 | 1 |
The encoded version of the creation function uses 66,355 fewer gas units than the struct version, about 40% of what createTokenSaleInfo burns in the same run.
Storage behavior
Looking at how Solidity actually stores the data:
StructBasedRegistrywrites four storage slots per entry: one for the address, one for eachuint256, and one for thebool.EncodedRegistrypacks everything into a single 32-byte value and writes one slot per entry.
The gas report lines up with this exactly: fewer writes, lower cost.
What we get:
A few things stand out from the gas results:
- Cheaper writes
The encoded layout drops the gas cost of creating records because writing one slot is much cheaper than writing several. That’s the whole game. - Less storage Each encoded record takes one storage word; the struct takes four. Across a registry with thousands of entries, that adds up.
- Deterministic layout The shift constants pin down where each field lives. Any contract using the same library can decode or verify a packed record with the same shifts and masks.
When to Use This (and When Not To)
This approach is a fit for service contracts: marketplaces, order books, metadata registries.
- Marketplaces and trading. A listing usually holds a seller, a price, and a token ID. Most NFT collections cap supply well under 4.29 billion, so 32-bit packing is safe. Order books with IDs or amounts that fit in 32 bits get the same win on placement and cancellation.
- The catch. The savings come out of your discipline. We have to guarantee that prices, IDs, and other values never exceed their allotted bits. As the code shows, explicit bounds checks aren’t optional. Without them, oversized inputs corrupt the record.
What This Says About the EVM
Underneath the syntax, this is about how high-level types map to 256-bit words:
- The struct layout mirrors the record one-to-one in your source, but the compiler scatters it across several words.
- The encoded layout collapses the same record into a single 256-bit word using fixed bit ranges.
Same data, two layouts. The experiment also shows that the arithmetic for packing and unpacking is cheap. It is much cheaper than the extra SSTOREs we save by avoiding separate slots.
Conclusion
Two contracts, same record, two storage strategies: StructBasedRegistry lets the compiler lay out a struct, and EncodedRegistry packs the fields into one bytes32 by hand. The example uses a TokenSale with four fields, but nothing here is specific to four; the technique scales to any record that fits in 256 bits. Foundry’s gas report shows the encoded version is meaningfully cheaper to write, which tracks directly with the slot count: one versus four.
The trade-off is real. EncodedRegistry gives we smaller storage and lower gas, but we pay for it in fixed bit ranges, an encoding step on every read and write, and a layout we have to maintain by hand. For registry-style contracts on EVM chains, that’s usually a trade worth making.