This article compares two ways to store a group of data in Solidity: (i) a mapping from addresses to a struct, and (ii) a mapping from addresses to a single bytes32 value made by packing the data together. The contracts are tested with Foundry, and the gas report shows that the packed layout, which uses 1 storage slot instead of 4, uses less gas to create records than the struct layout in the same situation. This article includes the full code, Foundry tests, gas results, and what these findings mean for building Ethereum Virtual Machine (EVM) smart contracts.
Code Overview
The comparison uses two separate contracts to demonstrate the different storage approaches. Both contracts store the same TokenSale data (a composite record with 4 fields, for example), but use fundamentally different storage layouts. This technique applies to any composite record, regardless of the number of fields.
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 | 244–255 | 12 bits (1 bit used) | Status flag |
Bit budget limit: There is a total of 256 bits of space (one bytes32 storage slot). In this example, 225 bits are used, leaving 31 bits for additional fields. How many fields you can fit depends on the type of data. For example, you could fit up to 256 true-or-false values, 32 small numbers, or 8 larger numbers in one slot. You need to carefully pick field sizes. tokenID and price are truncated to 32 bits (maximum value: 4,294,967,295), which might not work in all situations.
// 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 = 244;
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(data >> TOKEN_SALE_TOKEN_ID_SHIFT);
tokenSale.price = 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)
This contract uses Solidity’s native struct storage. The compiler handles storage 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
Note on storage packing: Solidity only packs consecutive variables that fit together in a 32-byte slot. Since uint256 fields come between address and bool, the compiler cannot pack them together. To achieve 3-slot storage (with address and bool packed in Slot 0), the struct would need to be reordered so the bool is declared immediately after the address. However, for this comparison, we keep the current ordering to demonstrate the worst-case scenario where no automatic packing occurs, making the gas savings of manual bit-packing even more significant.
Note on storage packing: Solidity only puts variables next to each other in the same 32-byte slot if they fit. Because the uint256 fields are between the address and bool, the compiler cannot put them together. To use only 3 slots (with address and bool in Slot 0), the struct would need to have the bool right after the address. For this example, we keep the current order to show the worst case in which no automatic packing occurs, making the gas savings from packing by hand even more important.
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(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) - Bits 244–255:
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
);
}
}
Main Design Idea
The core optimization is leveraging the EVM storage model by comparing two separate contract implementations:
-
StructBasedRegistry: uses Solidity’s built-in struct storage. With the current field order, the compiler puts the TokenSale struct into 4 storage slots. The number of slots depends on the field types and their order. Small types next to each other can be packed together, but here, the bool is separated from the address by two uint256 fields, so automatic packing does not happen.
-
EncodedRegistry: packs all fields into one bytes32 value using bit shifts. Each entry uses just one bytes32, reducing the number of SSTORE operations and saving gas per write. This method works for any set of fields that fit within 256 bits.
SSTORE operations use a lot of gas, so storing data in one slot instead of several leads to real savings.
Methodology: Storage Layouts and Test Setup
Storage layouts
StructBasedRegistry
The TokenSale struct uses 4 storage slots with the current field ordering. The address (20 bytes) occupies the first slot alone, each uint256 field occupies its own slot (32 bytes each), and the bool (1 byte) occupies the fourth slot. Field ordering matters—if the bool were declared after the address, they could be packed together in a single slot, reducing the total to 3 slots. However, this uses the unpacked layout to demonstrate the maximum gas savings achievable through manual bit-packing. This layout is managed by the Solidity compiler based on the field declaration order.
EncodedRegistry
The encoded approach manually packs all four fields into a single bytes32 using bit shifts. Constants like TOKEN_SALE_*_SHIFT define fixed bit ranges, and the TokenSaleEncoding.encode function combines fields via bitwise OR operations. This stores exactly one bytes32 per address key.
Test configuration
The test contract deploys both StructBasedRegistry and EncodedRegistry with identical test 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 records the gas usage for each test and for the underlying functions.
Key Findings
The gas report contains a summary table for tests and a detailed table for functions.
Test-level gas costs
| Test | Gas cost |
|---|---|
test_createTokenSaleInfo | 123,633 |
test_createTokenSaleWithEncode | 57,256 |
Under identical conditions, the encoded-path test uses 66,377 fewer gas units than the struct path test.
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 variant of the creation functions uses 66,355 fewer gas units than the struct variant. In relative terms, createTokenSaleWithEncode consumes approximately 40% of the gas required by createTokenSaleInfo in this measurement.
Storage behavior
Looking at both contract versions and how Solidity stores data:
- StructBasedRegistry writes four storage slots per entry: one slot for the address, one slot for each of the two uint256 values, and one slot for the bool.
- EncodedRegistry puts all the data into a single 32-byte value and uses just one storage operation for each entry.
The gas report matches this difference: fewer storage operations mean much lower gas costs.
Benefits Observed
The following benefits of the encoded layout are evident from the contracts and gas results:
- Lower Gas Usage for Creation
The encoded layout lowers the gas cost for creating records in tests. This is because writing to one slot costs less than writing to several slots, like with the struct. - Reduced On-Chain Storage per Record Each encoded record fits into one storage word, while the struct uses several slots. In a registry with many entries, the encoded layout uses fewer storage slots overall.
- Deterministic The bit shift values set a fixed way to organize the data. Any contract using the same library can rebuild or check a packed record by using the same shifts and masks.
Ideal Use Cases and Constraints
This method works best for service contracts like marketplaces, order books, or metadata registries.
- Marketplaces and trading: When listing an item, the contract stores details like the seller, price, and token ID. Most NFT collections have supply limits much lower than 4.29 billion, so packing these fields is safe and efficient. For fast trading or order books where order IDs or amounts fit in 32 bits, this method can also lower gas costs for placing and canceling orders.
- Developer responsibility: To save gas, you need careful data planning. Developers have to make sure that values such as prices and IDs never go beyond the set size. As shown in the code, adding clear checks is important to avoid data problems from larger inputs.
Theoretical Implications
At the EVM level, this code shows the link between high-level types and 256-bit storage words:
- Struct-based layout: This directly represents the record, but the compiler turns it into several storage words.
- Encoded layout: Manually puts the record into a single 256-bit word using set bit ranges, showing how the same data can be stored in different ways.
The experiment also shows that math operations for packing and unpacking cost much less gas than extra storage operations for separate slots.
Conclusion
This article shows two different Solidity contracts that use different ways to store a record: StructBasedRegistry (struct mapping managed by the compiler) and EncodedRegistry (manually packed into a 32-byte value). The example uses a TokenSale record with four fields, but the method works for records with any number of fields. The Foundry gas report for the tests shows that the encoded contract lowers the gas cost of creating records compared to the struct-based contract. This matches the drop in the number of storage slots per entry (1 vs. 4 with the current field order).
Overall, EncodedRegistry gives you smaller storage and lower gas use, but you have to work with fixed bit ranges, extra steps for encoding, and a stricter way to organize data. These features show the trade-off between making code easy to read and making it efficient for registry-style smart contracts on EVM blockchains.