uhex

uhex provides fixed-width lowercase hexadecimal encoders for unsigned integers and byte arrays, fully unrolled, with buffer-writing variants that never allocate.

Part of nurago, a collection of independent Go packages for backend services.

import "github.com/tecnickcom/nurago/pkg/uhex"

Encodes unsigned integers and fixed-size byte arrays as zero-padded lowercase hexadecimal, at widths known at compile time.

id  := uhex.Hex64(traceID)     // 16 bytes
sum := uhex.Hex32(checksum)    // 8 bytes

var dst [16]byte
uhex.Hex64UB(traceID, &dst)    // no allocation

The naming pattern

Four suffixes, orthogonal along two axes:

SuffixInputOutput
noneunsigned integerreturns []byte
UBunsigned integerwrites into a caller buffer
Bfixed-size byte arrayreturns []byte
BBfixed-size byte arraywrites into a caller buffer

Integer helpers always zero-pad to the full width of the type: Hex64 gives 16 characters, Hex32 8, Hex16 4, Hex8 2. Byte-array helpers encode each input byte in order, two characters per byte.

Measured

Functionns/opB/opallocs/op
Hex8UB0.6600
Hex8B0.6800
Hex80.7100
Hex8BB0.7200
Hex16BB0.8700
Hex32UB1.2700
Hex32BB1.5300
Hex16UB1.6900
Hex64UB2.0700
Hex64BB2.5600
Hex164.3300
Hex16B4.5000
Hex324.7400
Hex32B4.7900
Hex64B5.6100
Hex645.8400
Hex64Escaping12.37161
Hex64BEscaping12.66161

Measured with go test -bench on Go 1.27.1, linux/amd64, Intel Core i7-1260P. Run go test -bench=. ./pkg/uhex/ to reproduce them on your own hardware; the absolute values will differ, the ratios much less so.

Escape analysis is the whole story

Look at the last two rows against the rest.

Hex64 costs 5.84 ns and zero allocations when the returned slice does not escape the caller. The same function costs 12.37 ns and one 16-byte allocation when it does. The slice-returning helpers fill a local array and return a slice over it; whether that array lands on the stack or the heap is decided by escape analysis at each call site.

So the cost of Hex64 is not a property of Hex64. It depends on what you do with the result. Read the bytes and discard them, and it is free. Store the slice in a longer-lived structure, return it, send it on a channel, or pass it to a function the compiler cannot inline, and you pay an allocation.

The UB and BB variants remove the question. They write into a caller-owned array and never allocate, regardless of the call site. Hex64UB at 2.07 ns is roughly a third the cost of Hex64 even in the non-escaping case, because there is no slice header to construct.

For code that must not allocate, use the buffer-writing forms and stop reasoning about escape analysis.

Why it is fast

Every width is known at compile time, so the encoders are fully unrolled: no loops, no length checks, no argument validation. Each input byte is translated through a 256-entry lookup table and written with a single 16-bit store.

The cost scales linearly with output width and is independent of the input value, because there are no data-dependent branches. Hex8UB at 0.66 ns is a couple of instructions.

Against the standard library

The buffer-writing helpers run roughly two to three times faster than encoding/hex, which also allocates nothing in its Encode form.

Part of that gap is structural rather than a matter of implementation quality. encoding/hex encodes byte slices only, so encoding an integer with it requires serialising the value into a scratch buffer first. The integer helpers here read the value directly.

Against fmt.Sprintf("%x", v) the difference is about an order of magnitude, plus the allocations reflection-based formatting incurs.

encoding/hex covers what this does not: arbitrary-length data, streaming, and decoding. This package is specialised for small fixed widths up to eight bytes, and for that range it is unrolled in a way a general encoder cannot be.

Dependencies

This package reaches no external module: it uses only the Go standard library.