Skip to content

Gas Efficient Storage Bit Packing in Solidity: Reducing Gas Costs with Encoded Mappings

Posted on:October 29, 2025 at 09:26 AM

This article compares two alternative storage layouts for a composite record in Solidity: (i) a mapping from addresses to a high-level struct, and (ii) a mapping from addresses to a single bytes32 value produced by an encoding or bit-packing library. The contracts are tested with Foundry, and the gas report indicates that the encoded layout which reduces storage from four slots to one uses less gas for record creation compared to the struct-based layout under the same conditions. This article includes the complete code, Foundry tests, observed gas results, and practical and theoretical implications for designing Ethereum Virtual Machine (EVM)-based 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 four fields as an 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

FieldBitsSizePurpose
seller0–159160 bits (20 bytes)address
tokenID160–19132 bitsToken identifier
price192–22332 bitsSale price
isActive244–25512 bits (1 bit used)Status flag

Bit budget constraint: The total available space is 256 bits (one bytes32 storage slot). In this example, 225 bits are used, leaving 31 bits available for additional fields. The maximum number of fields depends on the data types—for instance, you could pack up to 256 boolean flags, 32 uint8 values, or 8 uint32 values in a single slot. Field sizes must be chosen carefully: tokenID and price are truncated to 32 bits (max value: 4,294,967,295), which may not suit all use cases.

// 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 automatically 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:

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.

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:

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:

Each SSTORE operation costs significant gas, so reducing from multiple slots to a single slot provides measurable savings.

Methodology: Storage Layouts and Test Setup

Storage layouts

StructBasedRegistry

The TokenSale struct uses four 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 immediately after the address, they could be packed together in a single slot, reducing the total to 3 slots. However, this comparison 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:

VariableValue
tokenID1
price100
seller0x123
isActivetrue

It then executes two test functions:

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

TestGas cost
test_createTokenSaleInfo123,633
test_createTokenSaleWithEncode57,256

Under identical conditions, the encoded-path test uses 66,377 fewer gas units than the struct path test.

Function-level gas costs

Function nameMinAvgMedianMax# calls
createTokenSaleInfo111,396111,396111,396111,3961
createTokenSaleWithEncode45,04145,04145,04145,0411

For the creation functions themselves, the encoded variant uses 66,355 fewer gas units than the struct variant. In relative terms, createTokenSaleWithEncode consumes about 40% of the gas consumed by createTokenSaleInfo in this measurement.

Storage behavior

Based on the two contract implementations and Solidity’s storage rules:

The gas report aligns with this difference: fewer SSTORE operations result in significantly lower gas cost.

Benefits Observed

The following benefits of the encoded layout are evident from the contracts and gas results:

  1. Lower Gas Usage for Creation
    The encoded layout reduces the gas cost for creating records during testing. The savings come from the reduced expense associated with a single-slot SSTORE compared to multiple-slot writes for the struct.

  2. Reduced On-Chain Storage per Record
    Each encoded record is represented by a single 256-bit word, while the struct representation occupies several slots. For a registry with many entries, the encoded layout results in fewer storage slots being occupied.

  3. Deterministic Physical Schema
    The bit-shift constants establish a fixed bit-level schema. Any contract that imports the same library can reconstruct or validate a packed record by applying the same shifts and masks.

  4. Selective Field Access
    Functions such as decodePrice and encodePrice operate specifically on the price subfield within the allocated bits. This allows for price updates and readings to be performed using just one storage word.

Practical Implications

For smart contract engineering, the two-contract comparison demonstrates:

Theoretical Implications

At the EVM level, this code demonstrates the relationship between high-level types and 256-bit storage words:

The experiment also highlights the EVM gas schedule: arithmetic operations for packing and unpacking are relatively inexpensive compared to the cost of additional SSTORE operations for separate storage slots.

Conclusion

This article presents two separate Solidity contracts implementing different storage layouts for a composite record: StructBasedRegistry (compiler-managed struct mapping) and EncodedRegistry (manually bit-packed bytes32 mapping). The example uses a four-field TokenSale struct, but the technique generalizes to records with any number of fields. The Foundry gas report for the provided tests indicates that the encoded contract reduces the gas cost of record creation compared to the struct-based contract. This reduction is consistent with the decrease in the number of storage slots used per entry (1 slot vs. 4 slots with the current field ordering).

Overall, EncodedRegistry achieves compact storage and lower measured gas usage, albeit at the cost of fixed bit ranges, additional encoding logic, and a more rigid schema. These characteristics illustrate the trade-off between readability and storage efficiency for registry-style smart contracts on EVM-based blockchains.