Complete Guide to the encoding/hex and encoding/base64 Packages

Use encoding/hex for hexadecimal conversion and encoding/base64 for Base64 encoding/decoding in Go.

You have bytes. The world wants text

You are debugging a network protocol. The packet capture shows a stream of bytes that looks like garbage to your text editor. Or you are writing a configuration file in JSON, and you need to embed a public key or a certificate. JSON only speaks strings. It cannot hold raw binary blobs. You need a bridge. You need to turn arbitrary bytes into a sequence of safe, printable characters, and turn them back later without losing a single bit.

That is what encoding packages do. They are not encryption. They are translation layers. They let binary data travel through text-only worlds. Go provides two standard tools for this job. encoding/hex converts bytes to hexadecimal strings. encoding/base64 converts bytes to Base64 strings. Both are simple, but they solve different problems. Choosing the wrong one wastes bandwidth or breaks your URLs.

Hex is the readable translator

Hexadecimal encoding is the straightforward approach. Every byte of data maps to exactly two hexadecimal digits. The byte 0x48 becomes the string 48. It is verbose. Your data size grows by 50 percent. It is also incredibly readable. You can look at a hex string and reverse-engineer the bytes in your head if you know the ASCII table.

Hex uses 16 symbols: 0 through 9 and a through f. Each symbol represents 4 bits. Two symbols represent 8 bits, which is exactly one byte. The math is clean. There is no padding. There is no complex alphabet. Hex is the default choice for debugging, logging, and low-level protocol inspection. When you see a memory dump or a hash, it is almost always hex.

Base64 is the density expert

Base64 is the efficiency expert. It packs three bytes into four characters. The output is only about 33 percent larger than the input. It uses a wider alphabet of 64 characters, including uppercase letters, lowercase letters, digits, plus, and slash. The trade-off is density versus simplicity. Hex is easier to debug. Base64 saves bandwidth.

Base64 works by treating three bytes as a 24-bit integer. It splits that integer into four 6-bit chunks. Each chunk maps to a character in the Base64 alphabet. If the input length is not a multiple of three, the encoder adds padding characters = to the end so the decoder knows where the data stops. This padding is a common source of bugs when protocols strip trailing characters.

Minimal example

package main

import (
	"encoding/base64"
	"encoding/hex"
	"fmt"
)

// EncodeDecodeDemo shows the basic round-trip for hex and base64.
func EncodeDecodeDemo() {
	// Start with raw binary data.
	// In real code this might be a file, a hash, or a network packet.
	data := []byte("Go is fun")

	// Hex encoding produces a string of two hex digits per byte.
	// The result is safe for logs, URLs, and human inspection.
	hexStr := hex.EncodeToString(data)
	fmt.Println("Hex:", hexStr)

	// Hex decoding reverses the process.
	// It returns an error if the string contains invalid characters.
	hexBytes, err := hex.DecodeString(hexStr)
	if err != nil {
		panic(err)
	}
	fmt.Println("Hex back:", string(hexBytes))

	// Base64 encoding uses a denser alphabet.
	// StdEncoding is the standard RFC 4648 variant.
	b64Str := base64.StdEncoding.EncodeToString(data)
	fmt.Println("Base64:", b64Str)

	// Base64 decoding also returns an error on bad input.
	// Padding characters '=' are handled automatically.
	b64Bytes, err := base64.StdEncoding.DecodeString(b64Str)
	if err != nil {
		panic(err)
	}
	fmt.Println("Base64 back:", string(b64Bytes))
}

The if err != nil pattern is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Decoding errors usually indicate corrupted data or a protocol mismatch. Swallowing the error with _ hides the problem. Use _ only when you have verified the input is safe or the error is impossible.

What happens under the hood

When you call hex.EncodeToString, the function iterates over your byte slice. It looks up each byte in a small table of 256 entries and writes two ASCII characters. It allocates a new byte slice for the result. The cost is linear. The allocation is the price you pay for convenience. If you are encoding in a tight loop, that allocation generates garbage. The package also provides hex.Encode(dst, src) which writes into a destination buffer you provide. This avoids allocation entirely.

Base64 does bit arithmetic. It grabs three bytes, treats them as a 24-bit integer, splits that integer into four 6-bit chunks, and maps each chunk to a character from the Base64 alphabet. If the input length is not a multiple of three, it pads the output with = signs. The decoder reverses the math. It reads four characters, reconstructs the 24-bit integer, and splits it back into three bytes. The decoder is strict. It rejects characters outside the alphabet and validates padding.

Hex is for humans. Base64 is for machines that speak text.

Realistic example: JSON and keys

JSON cannot hold raw bytes. If you try to marshal a []byte directly, the encoding/json package automatically encodes it as Base64. This is a helpful default, but it hides the encoding choice. When you are designing APIs or config files, it is better to be explicit. You control the encoding, and you control the decoding.

package main

import (
	"encoding/base64"
	"encoding/json"
	"fmt"
)

// PublicKey represents a simplified crypto key for storage.
// JSON cannot hold raw bytes, so we encode the key material.
type PublicKey struct {
	// Algorithm identifies the key type, like "ed25519" or "rsa".
	Algorithm string `json:"alg"`
	// KeyMaterial holds the binary key data encoded as Base64.
	// This ensures the JSON remains valid and portable.
	KeyMaterial string `json:"key"`
}

// StoreKey demonstrates encoding binary data for JSON storage.
func StoreKey() {
	// Simulate a raw binary public key.
	// In practice, this comes from crypto libraries.
	rawKey := []byte{0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0}

	// Encode the key so it survives JSON serialization.
	// Base64 is preferred here because it is more compact than hex.
	encodedKey := base64.StdEncoding.EncodeToString(rawKey)

	key := PublicKey{
		Algorithm:   "ed25519",
		KeyMaterial: encodedKey,
	}

	// Marshal to JSON. The key is now a safe string.
	jsonBytes, err := json.Marshal(key)
	if err != nil {
		panic(err)
	}
	fmt.Println("JSON:", string(jsonBytes))
}

The struct field KeyMaterial is a string because JSON only supports strings. The encoding happens before marshaling. This makes the contract clear. Anyone reading the JSON knows the value is Base64. If you relied on the automatic encoding, a consumer might not realize the field is binary data until they try to parse it. Explicit is better than implicit.

Streaming and memory

Encoding a small string is trivial. Encoding a 1-gigabyte file is dangerous if you load it all into memory. EncodeToString returns a new string containing the entire encoded result. For large data, you need streaming. The base64 package provides NewEncoder, which returns an io.Writer. You pipe data through the writer, and it encodes chunks as they arrive.

package main

import (
	"encoding/base64"
	"os"
)

// StreamEncode shows how to encode large data without loading it all into memory.
// This uses io.Writer to pipe data through the encoder.
func StreamEncode() {
	// Create a writer that encodes to Base64 as data flows through.
	// The encoder buffers internally and writes valid chunks.
	encoder := base64.NewEncoder(base64.StdEncoding, os.Stdout)

	// Write raw bytes. The encoder handles the transformation.
	// This works with files, network connections, or any io.Reader.
	encoder.Write([]byte("Large chunk of data..."))

	// Close the encoder to flush any buffered padding.
	// Forgetting to close can result in truncated output.
	encoder.Close()
}

The io.Writer pattern is idiomatic Go. Many packages implement it. You can chain encoders, compressors, and network connections together. The encoder buffers incomplete groups of three bytes. When you call Close, it flushes the buffer and writes any necessary padding. Always close the encoder. If you skip Close, the last few bytes might disappear.

Stream large data. Never allocate the whole blob.

Pitfalls and errors

Hex decoding fails if the string has an odd number of characters. The compiler cannot catch this at build time because the input is dynamic. At runtime, hex.DecodeString returns an error like encoding/hex: odd length hex string. If you pass a character that is not a hex digit, you get encoding/hex: invalid byte. Hex decoding is case-insensitive. 48 and 48 decode the same way. This is convenient for user input but can hide typos if you expect case-sensitive data.

Base64 has more traps. base64.StdEncoding.DecodeString rejects characters outside its alphabet. If you try to decode a URL that contains + or / and the URL parser mangled them, decoding fails. The error looks like illegal base64 data at input byte 12. Base64 has variants for different contexts. base64.StdEncoding uses + and /. These characters have special meaning in URLs. If you put Base64 in a URL, use base64.URLEncoding. It swaps + for - and / for _. Mixing them up causes silent corruption or decode errors.

Padding is another landmine. Some protocols strip the = padding characters. JWT tokens, for example, often omit padding. If you try to decode a padded string with base64.RawStdEncoding, it fails. If you decode an unpadded string with base64.StdEncoding, it might fail or produce garbage. RawStdEncoding and RawURLEncoding handle input without padding. They also produce output without padding. Choose the variant that matches your protocol.

Hex is simple but verbose. Base64 is dense but fragile. Check the padding. Verify the alphabet.

Decision matrix

Use hex.EncodeToString when you need human-readable debugging output or when the data size is small and clarity matters more than bandwidth. Use hex.DecodeString when you receive hex strings from logs, configuration files, or legacy protocols that expect hex. Use base64.StdEncoding when you are embedding binary data in JSON, XML, or other text-based formats where standard Base64 is expected. Use base64.URLEncoding when the encoded string will appear in a URL or a filename, because the standard characters break URL parsing. Use base64.RawStdEncoding when you are processing data that omits padding characters, which is common in JWT tokens and some cryptographic libraries. Use hex.Encode(dst, src) or base64.NewEncoder when you are processing large data and need to avoid allocating the entire result in memory. Use raw byte slices for internal processing; only encode at the boundaries where you must cross into a text-only medium.

Encoding is not encryption. If you need secrecy, use crypto.

Where to go next