BlockchainSoliditySmart Contracts13 min readUpdated

Solidity Mapping with bytes32: Document Registry and Role Identifiers

By Mudassir Khan — Agentic AI Consultant & AI Systems Architect, Islamabad, Pakistan

Cover illustration for: Solidity Mapping with bytes32: Document Registry and Role Identifiers

Section 01 · Definition

What is a Solidity mapping with bytes32?

bytes32 is a fixed-size byte array that holds up to 32 bytes of data. As a mapping key it is cheaper than string and more flexible than address — ideal for hash-based identifiers and role names.

Quick answer

In one sentence: mapping(bytes32 => ValueType) uses a 32-byte fixed value as the key — typically a keccak256 hash of a document, role name, or identifier — giving each hash its own storage slot at O(1) cost with no dynamic encoding overhead.

The EVM stores state in 32-byte slots. bytes32 fills exactly one slot, which means the compiler can hash it directly into a mapping key without any ABI encoding step. A string key requires dynamic-length encoding before hashing, which costs more gas and cannot be computed at compile time. bytes32 avoids both problems.

In practice, bytes32 keys come from two sources: the keccak256 hash of arbitrary content (document fingerprinting, role identification), or a short string cast directly to bytes32 (when the label fits in 32 characters and you need it to be readable in storage viewers). The two uses appear in different contexts but the mapping mechanics are identical.

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

contract Bytes32KeyDemo {

    // Content hash => registered (document fingerprint registry)
    mapping(bytes32 => bool) public isRegistered;

    // Content hash => metadata struct
    struct Record { address author; uint40 timestamp; string label; }
    mapping(bytes32 => Record) public records;

    // Role identifier => account => has role (OpenZeppelin pattern)
    bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
    bytes32 public constant ADMIN_ROLE  = keccak256("ADMIN_ROLE");
    mapping(bytes32 => mapping(address => bool)) public hasRole;

    // Short label (<=32 chars) cast directly to bytes32
    function lookupByLabel(string calldata _label)
        external view returns (bool)
    {
        bytes32 key = bytes32(bytes(_label));  // safe only for strings <=32 chars
        return isRegistered[key];
    }
}

Section 02 · Document registry pattern

Using bytes32 to build an on-chain document registry

A document registry stores the keccak256 hash of a file on chain alongside metadata. Anyone can verify that a document existed at a specific time without storing the file itself.

Flow diagram showing a document being hashed with keccak256 off-chain, the resulting bytes32 key being submitted to the smart contract, and a Record struct being stored at that key in the mapping.
The document itself never goes on chain. Only its keccak256 fingerprint and metadata are stored — keeping gas costs low while providing tamper-evident timestamping.
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

/// @title DocumentRegistry
/// @notice Store a keccak256 content hash on chain with author and timestamp.
///         Verify that a document existed at or before a given block.
contract DocumentRegistry {

    // ── Data structures ──────────────────────────────────────────────
    struct DocRecord {
        address  author;      // who registered the document
        uint40   timestamp;   // block.timestamp at registration
        uint32   blockNumber; // block.number at registration
        string   label;       // human-readable description
        bool     revoked;     // can the author revoke the registration?
    }

    // bytes32 content hash => record
    mapping(bytes32 => DocRecord) public registry;

    // author => list of their registered hashes
    mapping(address => bytes32[]) public authorDocuments;

    // ── Events ───────────────────────────────────────────────────────
    event Registered(
        bytes32 indexed contentHash,
        address indexed author,
        string  label,
        uint256 timestamp
    );
    event Revoked(bytes32 indexed contentHash, address indexed author);

    // ── Registration ─────────────────────────────────────────────────

    /// @notice Register a document by its keccak256 content hash.
    /// @param _contentHash  keccak256(document_bytes) computed off-chain
    /// @param _label        human-readable description (e.g. "Q3 Audit Report")
    function register(bytes32 _contentHash, string calldata _label) external {
        require(_contentHash != bytes32(0), "empty hash");
        require(registry[_contentHash].timestamp == 0, "already registered");

        registry[_contentHash] = DocRecord({
            author:      msg.sender,
            timestamp:   uint40(block.timestamp),
            blockNumber: uint32(block.number),
            label:       _label,
            revoked:     false
        });

        authorDocuments[msg.sender].push(_contentHash);

        emit Registered(_contentHash, msg.sender, _label, block.timestamp);
    }

    /// @notice Revoke a registration (author only).
    function revoke(bytes32 _contentHash) external {
        DocRecord storage rec = registry[_contentHash];
        require(rec.timestamp != 0, "not registered");
        require(rec.author == msg.sender, "not author");
        require(!rec.revoked, "already revoked");
        rec.revoked = true;
        emit Revoked(_contentHash, msg.sender);
    }

    // ── Verification ─────────────────────────────────────────────────

    /// @notice Returns true if the hash is registered and not revoked.
    function verify(bytes32 _contentHash) external view returns (bool) {
        DocRecord storage rec = registry[_contentHash];
        return rec.timestamp != 0 && !rec.revoked;
    }

    /// @notice Returns (author, timestamp, blockNumber, label, revoked).
    function getRecord(bytes32 _contentHash)
        external view
        returns (address, uint40, uint32, string memory, bool)
    {
        DocRecord storage rec = registry[_contentHash];
        return (rec.author, rec.timestamp, rec.blockNumber, rec.label, rec.revoked);
    }

    /// @notice How many documents has an address registered?
    function documentCount(address _author) external view returns (uint256) {
        return authorDocuments[_author].length;
    }

    /// @notice Get the nth document hash for an author (0-indexed).
    function getAuthorDocument(address _author, uint256 _index)
        external view returns (bytes32)
    {
        return authorDocuments[_author][_index];
    }

    // ── Convenience ──────────────────────────────────────────────────

    /// @notice Hash a bytes payload on chain (mirrors keccak256 off-chain).
    ///         Off-chain: ethers.utils.keccak256(ethers.utils.toUtf8Bytes(content))
    function hashContent(bytes calldata _content) external pure returns (bytes32) {
        return keccak256(_content);
    }
}

Deploy this on Remix. Hash a string off-chain — use the hashContent convenience function with any bytes value — then call register with that bytes32 hash and a label. Call verify with the same hash to confirm the registration. Try registering the same hash twice to see the duplicate guard revert.

Section 03 · bytes32 role identifiers

Role-based access control with bytes32 keys

The OpenZeppelin AccessControl pattern uses keccak256 of role name strings as bytes32 keys. This gives roles meaningful names in your source code while keeping on-chain storage to one slot per (role, account) pair.

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

/// @title RoleRegistry — bytes32 role identifiers with nested bool mapping
contract RoleRegistry {

    // Role constants — keccak256 of the role name string
    bytes32 public constant DEFAULT_ADMIN_ROLE = 0x00;
    bytes32 public constant MINTER_ROLE   = keccak256("MINTER_ROLE");
    bytes32 public constant BURNER_ROLE   = keccak256("BURNER_ROLE");
    bytes32 public constant PAUSER_ROLE   = keccak256("PAUSER_ROLE");
    bytes32 public constant AUDITOR_ROLE  = keccak256("AUDITOR_ROLE");

    // role => account => bool — the permission matrix
    mapping(bytes32 => mapping(address => bool)) private _roles;

    // role => admin role that can grant/revoke it
    mapping(bytes32 => bytes32) public getRoleAdmin;

    event RoleGranted(bytes32 indexed role, address indexed account, address indexed sender);
    event RoleRevoked(bytes32 indexed role, address indexed account, address indexed sender);

    constructor() {
        // Deployer gets the default admin role
        _grantRole(DEFAULT_ADMIN_ROLE, msg.sender);

        // Set up role admin hierarchy
        getRoleAdmin[MINTER_ROLE]  = DEFAULT_ADMIN_ROLE;
        getRoleAdmin[BURNER_ROLE]  = DEFAULT_ADMIN_ROLE;
        getRoleAdmin[PAUSER_ROLE]  = DEFAULT_ADMIN_ROLE;
        getRoleAdmin[AUDITOR_ROLE] = DEFAULT_ADMIN_ROLE;
    }

    // ── Modifiers ────────────────────────────────────────────────────

    modifier onlyRole(bytes32 _role) {
        require(hasRole(_role, msg.sender), "missing role");
        _;
    }

    // ── Public view ──────────────────────────────────────────────────

    function hasRole(bytes32 _role, address _account) public view returns (bool) {
        return _roles[_role][_account];
    }

    // ── Management ───────────────────────────────────────────────────

    function grantRole(bytes32 _role, address _account) external {
        require(hasRole(getRoleAdmin[_role], msg.sender), "not role admin");
        _grantRole(_role, _account);
    }

    function revokeRole(bytes32 _role, address _account) external {
        require(hasRole(getRoleAdmin[_role], msg.sender), "not role admin");
        _revokeRole(_role, _account);
    }

    /// @notice Give up a role you hold — no admin approval needed.
    function renounceRole(bytes32 _role) external {
        _revokeRole(_role, msg.sender);
    }

    // ── Internal ─────────────────────────────────────────────────────

    function _grantRole(bytes32 _role, address _account) internal {
        if (!_roles[_role][_account]) {
            _roles[_role][_account] = true;
            emit RoleGranted(_role, _account, msg.sender);
        }
    }

    function _revokeRole(bytes32 _role, address _account) internal {
        if (_roles[_role][_account]) {
            _roles[_role][_account] = false;
            emit RoleRevoked(_role, _account, msg.sender);
        }
    }

    // ── Role-gated actions ───────────────────────────────────────────

    function mint(address _to) external onlyRole(MINTER_ROLE) {
        // minting logic
    }

    function pause() external onlyRole(PAUSER_ROLE) {
        // pause logic
    }

    function audit() external onlyRole(AUDITOR_ROLE) {
        // read-only auditing logic
    }
}

Why not use an enum for roles?

An enum compiles down to a uint8, which cannot be extended without modifying the contract. bytes32 role identifiers are open-ended: any off-chain tool or future contract can compute keccak256('NEW_ROLE') and use it without changing the core storage layout. That extensibility is why the OpenZeppelin AccessControl standard chose bytes32 over enum.

Section 04 · String to bytes32

Converting between string and bytes32 in Solidity

Short strings convert to bytes32 with a cast. Longer strings must be hashed. Knowing which to use prevents silent truncation bugs.

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

contract StringBytes32 {

    // ── Short string (<=32 chars) => bytes32 via cast ─────────────────
    // The bytes() cast converts string to a dynamic byte array.
    // The bytes32() cast then pads it with zeros to fill 32 bytes.
    // Safe ONLY when len(_s) <= 32.
    function shortStringToBytes32(string calldata _s)
        external pure returns (bytes32)
    {
        require(bytes(_s).length <= 32, "string too long for direct cast");
        return bytes32(bytes(_s));
    }

    // ── Any-length string => bytes32 via keccak256 ────────────────────
    // This loses the original string — you cannot reverse the hash.
    // Use this for role identifiers and content fingerprinting.
    function hashString(string calldata _s) external pure returns (bytes32) {
        return keccak256(bytes(_s));
    }

    // ── bytes32 => string (only works for short strings) ─────────────
    // Strips trailing zero bytes and returns the printable string.
    // Will produce garbage for keccak256 hashes (they are not valid UTF-8).
    function bytes32ToString(bytes32 _b) external pure returns (string memory) {
        uint256 len = 32;
        while (len > 0 && _b[len - 1] == 0) { len--; }
        bytes memory result = new bytes(len);
        for (uint256 i = 0; i < len; i++) { result[i] = _b[i]; }
        return string(result);
    }

    // ── Compile-time constants use keccak256() directly ───────────────
    bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");

    // ── Mapping with bytes32 short-string key ─────────────────────────
    mapping(bytes32 => address) public labelToAddress;

    function register(string calldata _label, address _addr) external {
        require(bytes(_label).length <= 32, "label too long");
        bytes32 key = bytes32(bytes(_label));
        labelToAddress[key] = _addr;
    }

    function lookup(string calldata _label) external view returns (address) {
        require(bytes(_label).length <= 32, "label too long");
        return labelToAddress[bytes32(bytes(_label))];
    }
}

The direct cast bytes32(bytes(_s)) pads the string with zeros on the right to fill 32 bytes. This is reversible — you can strip the trailing zeros and recover the original string. The keccak256 hash is not reversible. Use the cast for human-readable labels that fit in 32 characters; use the hash for everything longer or for opaque identifiers.

Section 05 · Common mistakes

Three bytes32 mapping mistakes to avoid

Using bytes32(0) as a valid key

bytes32(0) — 32 zero bytes — is the zero value for the type and the natural sentinel for 'not found'. If you use it as a valid document hash or role ID, you lose the ability to distinguish 'this entry was never written' from 'the all-zeros hash was intentionally registered'. Always check require(_hash != bytes32(0)) before accepting a caller-supplied bytes32 key.

Direct-casting strings longer than 32 characters

bytes32(bytes('a string with more than 32 characters in total')) silently truncates the input to the first 32 bytes. The compiler does not error. This produces a key that does not match the full string and causes silent lookup failures. Always check require(bytes(_s).length <= 32) before the cast, or use keccak256(bytes(_s)) for strings of any length.

Exposing the raw bytes32 key in a public getter without a label mapping

If your mapping is public, the auto-generated getter takes a bytes32 argument. Frontend callers must supply the hash — they cannot discover what hashes are valid by querying the contract. Pair your bytes32 mapping with an event that logs the hash alongside the original label on registration, or store the label in a parallel mapping(bytes32 => string). That makes the registry navigable off-chain.

FAQ

Frequently asked questions

What is mapping(bytes32 => ...) used for in Solidity?

It is used for hash-based registries and role identifiers. The most common patterns are: a document fingerprint registry where keccak256(content) is the key, a role-based access control system where keccak256('MINTER_ROLE') is the key, and a short-string to address lookup where a label cast to bytes32 is the key. bytes32 is more gas efficient than string as a mapping key because it requires no dynamic encoding.

Why is bytes32 more gas efficient than string as a mapping key in Solidity?

The EVM stores state in fixed 32-byte slots. bytes32 fits into one slot and can be hashed directly. A string key requires dynamic-length ABI encoding before hashing, which involves reading multiple slots for long strings. For short strings both cost similarly, but bytes32 is consistent regardless of content length and avoids any risk of unexpected costs from longer strings.

How do you convert a string to bytes32 in Solidity?

For strings of 32 characters or fewer: bytes32 key = bytes32(bytes(myString)). This pads with zeros and is reversible. For strings of any length: bytes32 key = keccak256(bytes(myString)). This produces a fixed-size hash and is not reversible. Compile-time constants use keccak256 directly: bytes32 constant ROLE = keccak256('ADMIN_ROLE').

What is the OpenZeppelin bytes32 role pattern in Solidity?

OpenZeppelin AccessControl stores roles as bytes32 constants computed from keccak256 of the role name string, for example bytes32 constant MINTER_ROLE = keccak256('MINTER_ROLE'). A nested mapping(bytes32 => mapping(address => bool)) tracks which accounts hold each role. This is extensible — any new role can be added without modifying the storage layout.

Can you use bytes (dynamic) as a mapping key in Solidity?

No. Dynamic types — bytes, string, and arrays — cannot be used as mapping keys in Solidity. Only value types (uint, address, bool, bytes1 through bytes32, enum) are valid as mapping keys. If you need to key a mapping by the content of a dynamic byte array, hash it first with keccak256 to produce a bytes32 key.

Written by Mudassir Khan

Agentic AI consultant and AI systems architect based in Islamabad, Pakistan. CEO of Cube A Cloud. 38+ agentic AI launches delivered for global founders and CTOs.

View agentic AI consulting serviceSee ChainTrust case study

Related service

Agentic AI Consulting

See scope & pricing →

Related case study

ChainTrust Compliance Engine

Read case study →

More on this topic

Need an AI systems architect?

Book a 30-minute architecture call. I will sketch the high-level design for your use case and give you an honest view of the trade-offs.

Book a strategy call →