Binary data in text form
You're staring at a log file. The database returned a blob of binary data, but the logger only prints text. The output looks like garbage characters or escaped sequences. You need a way to represent those raw bytes as a readable string so you can copy-paste the value into a debugger or send it over an API without corrupting the payload. Hexadecimal encoding solves this by turning every byte into two safe, printable characters.
How hex maps bytes to characters
Hexadecimal is base-16. A single byte holds 256 possible values, ranging from 0 to 255. You can't type a byte directly into a text file, a JSON string, or a URL. Hex maps each byte to two characters from the set 0-9 and a-f. The byte value 255 becomes ff. The byte value 0 becomes 00. The byte value 65 (which is the ASCII code for A) becomes 41.
This mapping works because a hex digit represents exactly 4 bits. A byte has 8 bits. Two hex digits cover the full byte. The encoding doubles the size of the data, but it makes binary data safe to store in text formats, logs, and configuration files. Hex is also case-insensitive for decoding, which reduces friction when humans copy and paste values between systems.
Hex doubles the size. Buy readability with space.
Minimal round-trip
Here's the simplest round-trip: encode bytes to a hex string, then decode it back. The encoding/hex package provides EncodeToString and DecodeString for this exact pattern.
package main
import (
"encoding/hex"
"fmt"
)
func main() {
// Raw bytes representing the word "Go"
data := []byte("Go")
// EncodeToString converts each byte to two hex characters
encoded := hex.EncodeToString(data)
fmt.Println(encoded) // prints: 476f
// DecodeString parses the hex string back into bytes
decoded, err := hex.DecodeString(encoded)
if err != nil {
// Handle invalid hex input
panic(err)
}
fmt.Println(string(decoded)) // prints: Go
}
hex.EncodeToString allocates a new string. It iterates over the input slice, converting each byte to two ASCII characters. The output length is always exactly len(input) * 2. hex.DecodeString does the reverse. It checks that the input length is even. If the length is odd, the function returns an error at runtime. It also validates that every character is a valid hex digit. If you pass Gz, the decoder rejects it because z isn't in the hex alphabet.
Real-world pattern: IDs and validation
Real code rarely just encodes "Go". You usually encode hashes, UUIDs, or random tokens. Here's a pattern for generating a hex-encoded ID and validating user input. The GenerateID function creates a random token, and ValidateHex checks that incoming data is well-formed.
package main
import (
"crypto/rand"
"encoding/hex"
"fmt"
)
// GenerateID creates a random 16-byte token and returns it as a hex string.
func GenerateID() (string, error) {
// 16 bytes becomes a 32-character hex string
b := make([]byte, 16)
// rand.Read fills the slice with cryptographically secure random data
_, err := rand.Read(b)
if err != nil {
return "", err
}
// EncodeToString produces lowercase hex by default
return hex.EncodeToString(b), nil
}
func main() {
// Discard the error for brevity in this example
id, _ := GenerateID()
fmt.Println(id) // prints: a3f2c8...
}
The ValidateHex function ensures the input is a valid hex string of the expected length. This is useful when you receive data from a client and need to verify it before processing.
// ValidateHex ensures the input is a valid hex string of the correct length.
func ValidateHex(s string) bool {
// DecodeString checks for valid hex characters and even length
_, err := hex.DecodeString(s)
if err != nil {
return false
}
// 16 bytes encode to exactly 32 hex characters
return len(s) == 32
}
The encoding/hex package follows Go's convention of returning errors rather than panicking. Always check the error from DecodeString. Silent failures on bad input lead to corrupted data downstream. Also, EncodeToString returns lowercase hex. This is the community standard. If you see uppercase hex in the wild, it's usually the result of a manual conversion, not the standard library. Stick with lowercase unless a specific protocol demands otherwise.
Debugging with hex dumps
When you're debugging a protocol or a file format, a flat hex string isn't always enough. You want to see the structure. hex.Dump produces a multi-line output with offsets, hex bytes, and ASCII representation. It's the Go equivalent of a hex editor view.
package main
import (
"encoding/hex"
"fmt"
)
func main() {
// Data containing a null byte and mixed content
data := []byte("Hello\x00World")
// Dump creates a formatted hex dump with offsets and ASCII
dump := hex.Dump(data)
fmt.Print(dump)
}
The output looks like this:
00000000 48 65 6c 6c 6f 00 57 6f 72 6c 64 |Hello.World|
The left column shows the byte offset. The middle column shows the hex bytes. The right column shows the ASCII representation, with non-printable characters replaced by dots. hex.Dump is invaluable for spotting null bytes, control characters, or misaligned data in binary protocols.
Streaming and performance
When dealing with large files or network streams, you don't want to load everything into memory. hex.NewEncoder wraps an io.Writer and encodes data as it flows. You write bytes to the encoder, and it writes hex characters to the underlying writer. This keeps memory usage constant regardless of input size.
package main
import (
"encoding/hex"
"os"
)
func main() {
// NewEncoder wraps stdout to stream hex output
enc := hex.NewEncoder(os.Stdout)
// Write bytes to the encoder; hex characters flow to stdout
enc.Write([]byte("Stream"))
}
For high-throughput paths where you're encoding large buffers in a loop, EncodeToString allocates a new string every time. This adds pressure to the garbage collector. Use hex.Encode with a pre-allocated buffer to avoid allocations. The Encode function writes hex characters into a destination slice. The destination must be twice the size of the source.
// Encode writes hex characters into a pre-allocated buffer.
func EncodeToBuffer(src []byte, dst []byte) int {
// dst must be at least 2 * len(src) to hold the output
// Encode returns the number of bytes written to dst
return hex.Encode(dst, src)
}
Reusing a buffer across iterations eliminates allocation overhead. This matters when you're encoding megabytes of data per second.
Pitfalls and errors
The most common runtime error comes from odd-length strings. Hex pairs map one-to-one with bytes. If you pass a string with an odd number of characters, the decoder has no way to pair the last character. The function returns an error like encoding/hex: odd length hex string. You won't see this at compile time. The compiler only checks types. hex.DecodeString accepts a string. If you pass a string with an odd length, the program compiles fine and fails when the function runs.
Another pitfall is invalid characters. If the input contains g, z, spaces, or underscores, the decoder rejects it with encoding/hex: invalid byte. Some systems use underscores to group hex digits for readability. The standard decoder doesn't accept underscores. Strip them before decoding.
Case handling is another detail. DecodeString accepts both A and a. EncodeToString always produces lowercase. If your system requires uppercase hex, you need to convert the result manually using strings.ToUpper. Don't assume the encoder preserves case.
Decode errors are runtime surprises. Validate input length before you decode.
When to use hex
Use hex.EncodeToString when you need a quick, readable representation of small binary data like hashes or short IDs. Use hex.Encode with a pre-allocated buffer when you are encoding large payloads in a performance-critical loop and want to avoid allocations. Use hex.DecodeString when you receive hex data from user input, configuration files, or APIs and need to recover the original bytes. Use hex.NewEncoder when you need to stream hex output to a file or network connection without buffering the entire payload in memory. Use base64 encoding when storage space matters more than human readability, since base64 expands data by only 33 percent compared to hex's 100 percent expansion. Use raw bytes in memory when the data stays within your program and never crosses a text boundary like a log file or network request.