Why does a []byte struct field marshal to a base64 string in encoding/json?
answer
- JSON has no byte type
- bytes must survive as text
- four characters per three bytes
- standard alphabet, padded
- a [32]byte is not a slice
basics
~20 sJSON has no type for raw bytes, so encoding/json marshals a []byte field as a base64 string using the standard padded alphabet. A nil []byte becomes null and an empty one becomes an empty string.
solid answer
~40 sJSON's value types are object, array, string, number, boolean and null — none of them carry raw bytes. So `encoding/json` special-cases a slice of `byte`: it emits a JSON string holding the base64 of those bytes, using `base64.StdEncoding` (the RFC 4648 standard alphabet with `=` padding), and reverses that on unmarshal. Two details bite people. A `nil` []byte marshals to `null` while an empty non-nil one marshals to `""`. And a fixed-size array is not a slice to the encoder: a `[32]byte` digest marshals as a JSON array of 32 numbers, not a base64 string. The alphabet is not configurable; if you want hex or the URL-safe alphabet on the wire, use a `string` field or give the type its own `MarshalJSON`.
code
go · 8 linestype Blob struct {
Digest []byte
Tag [4]byte
}
b, _ := json.Marshal(Blob{Digest: []byte("hi"), Tag: [4]byte{1, 2, 3, 4}})
fmt.Println(string(b))
// {"Digest":"aGk=","Tag":[1,2,3,4]}go deeper
Be ready to state the rule out loud: a []byte field turns into a base64 string because JSON cannot carry raw bytes. Knowing that nil becomes null is the usual second beat.
Explain the mechanics: which alphabet and padding are used, why an array of bytes behaves differently from a slice, and how a type's own MarshalJSON overrides the default.
Show the operational angle — that clients cannot infer the encoding from the string, so the representation belongs in the API contract, and that nil versus empty changes what a consumer's decoder sees.
Frame it as a published contract decision: once a field is base64 on the wire, the alphabet and the null-versus-empty behaviour are things partners depend on, and changing them later is a breaking change.
## The problem JSON leaves you with JSON defines exactly six value kinds: object, array, string, number, boolean and null. There is no byte-string type. Anything binary — a hash, a digest, an encrypted token, a small image — has to be *armoured* into characters before it can travel inside a JSON document. `encoding/json` makes that decision for you. When it meets a value whose type is a slice of `byte` (that is, `[]byte`, or any named type whose underlying type is `[]uint8`), it writes a JSON **string** containing the **base64** encoding of the bytes, and it uses `base64.StdEncoding`: the RFC 4648 standard alphabet `A–Z a–z 0–9 + /`, padded with `=` so the length is a multiple of four. ```go type Blob struct { Digest []byte } // json.Marshal(Blob{Digest: []byte("hi")}) -> {"Digest":"aGk="} ``` Unmarshaling is the mirror image: a JSON string being decoded into a `[]byte` field is base64-decoded, and a string that is not valid base64 produces an error rather than a garbage slice. ## Why base64 and not hex Base64 costs four output characters for every three input bytes — about 33% inflation — where hex costs two characters per byte, or 100%. Every character in the standard base64 alphabet is also a character JSON can put in a string without escaping. For a format whose job is to move opaque payloads compactly, base64 is the obvious default, and it is the same choice most JSON APIs make by hand. ## The edge cases worth memorising - **nil versus empty.** `var b []byte` is nil and marshals to `null`. `b := []byte{}` is non-nil and marshals to `""`. Round-tripping does not preserve the distinction in the direction you might hope: decoding `null` into a `[]byte` field leaves it nil, and decoding `""` gives you an empty non-nil slice. - **Arrays are not slices.** `[4]byte` and `[32]byte` are arrays. The encoder's byte-slice rule does not apply to them, so they marshal as a JSON array of numbers: `[1,2,3,4]`. This surprises people who store a `sha256.Sum256` result — which is a `[32]byte` — directly in a wire struct. Slice it (`sum[:]`) if you want the base64 string. - **Named types still count.** `type Token []byte` marshals as base64 too, because the encoder looks at the underlying type. - **`json.RawMessage` is the exception.** It is defined as a `[]byte`, but it declares `MarshalJSON`, so the encoder uses that instead of the byte-slice rule and splices the bytes in as literal JSON. - **Map values too.** A `map[string][]byte` gets base64 strings for its values by the same rule. ## Changing the representation The alphabet is fixed; there is no struct-tag option for URL-safe or unpadded base64, and none for hex. Three practical ways out: 1. Declare the field as a `string` and encode it yourself — `base64.RawURLEncoding.EncodeToString(b)` or `hex.EncodeToString(b)` — at the boundary where you build the wire struct. 2. Give the field a named type with its own `MarshalJSON`/`UnmarshalJSON`, or a text marshaler, so every use of the type agrees on the representation. 3. Keep the wire type separate from the domain type, so the domain keeps `[]byte` and only the wire struct carries the encoded string. Whichever you pick, write it down for consumers: a partner integrating against your API cannot tell from a 22-character string whether it is standard base64, URL-safe base64 or hex, and picking the wrong decoder is one of the most common integration bug reports on binary fields. ## What an interviewer is listening for That you know the rule (`[]byte` → base64 string), that you know *why* (JSON has no byte type), and that you do not confuse it with a byte array. The follow-up is usually the nil/empty distinction, because it decides whether a client sees `null` or `""` and therefore whether their own decoder blows up.
- Does a [16]byte array field marshal the same way as a []byte field?No. The base64 rule applies to slices of byte only. An array marshals as a JSON array of numbers, so a `[16]byte` becomes `[12,34,...]` with sixteen entries. If you want the base64 string, slice it first with `arr[:]` when you build the wire struct.
- Which base64 alphabet does encoding/json use, and can you switch it to the URL-safe one?It uses `base64.StdEncoding` — the standard alphabet with `+`, `/` and `=` padding. There is no tag or option to change it. To put a URL-safe value on the wire, declare a `string` field and call `base64.RawURLEncoding.EncodeToString` yourself, or give the type its own `MarshalJSON`.
- Why doesn't json.RawMessage come out as base64, even though it is a []byte?Because `json.RawMessage` declares a `MarshalJSON` method that returns its bytes unchanged. A marshaler method takes precedence over the encoder's default rules, so the raw bytes are spliced into the output as literal JSON instead of being armoured into a string.
- What happens when a client sends a string that is not valid base64 into a []byte field?`json.Unmarshal` returns an error rather than filling the field with partial data — the base64 decode failure is reported like any other type error for that field. Everything decoded before the failure may already be set, so treat the whole result as unusable on error.
JSON is a text-only envelope. Raw bytes have to be turned into letters before they can be posted in it — the same reason a binary attachment is encoded to text before it travels in an email body.
saying these in an interview costs you the question
- Says JSON has a native byte-array type
- Expects a []byte to appear as a list of numbers
- Thinks nil and empty byte slices both encode as null
- Assumes the URL-safe alphabet is used
- Passes a [32]byte digest and expects a base64 string