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.
// 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.
// 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.
// 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.
// 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.