Base64 in Go
Go has four Base64 encodings, and its decoder rejects input that JavaScript decodes without complaint. Both facts cost people time, so this page starts with the measurement.
By the Withuse team · Updated
The same three inputs, three languages
We ran one decode across Go 1.22 (darwin/arm64), Python 3.12 and Node 22. Nothing here is quoted from documentation — every cell is what the runtime actually did.
| Input | JavaScript atob | Python b64decode | Go StdEncoding |
|---|---|---|---|
"YQ==" | ✅ | ✅ | ✅ |
"YQ" — padding omitted | ✅ returns "a" | ❌ Incorrect padding | ❌ illegal base64 data at input byte 0 |
"w7vDv8O-" — URL alphabet | ❌ | ❌ (with validate) | ❌ at byte 7 |
Go is the strict one, and it is strict in both directions. RawStdEncoding.DecodeString("YQ==") fails at byte 2, because for that encoding the padding should not be there at all. The variant you name has to match the data exactly — there is no tolerance in either direction.
That reads like a nuisance and is actually the useful behaviour. A mismatched variant fails at the decode, at a named byte offset, in the function you just called. The permissive alternative is worse: JavaScript hands back plausible bytes, those bytes get written somewhere, and the problem surfaces two systems downstream as corrupted data with no obvious origin. A loud error costs minutes. Wrong bytes cost days and a bad record you may not be able to reconstruct.
Four encodings, two axes
b := []byte("Hello, 안녕 🎉")
base64.StdEncoding.EncodeToString(b) // + / with padding
base64.URLEncoding.EncodeToString(b) // - _ with padding
base64.RawStdEncoding.EncodeToString(b) // + / no padding
base64.RawURLEncoding.EncodeToString(b) // - _ no padding
// all four returned: "SGVsbG8sIOyViOuFlSDwn46J"Alphabet and padding are independent choices, which is where the four come from. And that output carries the trap worth knowing about: for this particular input, all four encodings agree. The encoded form happens to contain no + or /, and its length is already a multiple of four, so there is nothing for the variants to disagree about.
The consequence is that picking the wrong encoding can pass every test you write. Your fixtures encode cleanly, your round-trip test passes, the code ships — and then one payload produces a + and a URL breaks in production months later. Choose by destination rather than by what worked in the test: RawURLEncoding for URLs, filenames and JWT segments, since that combination is what base64url means everywhere else; StdEncoding for binary in JSON or a database column.
No conversion step, but watch len
text := "Hello, 안녕 🎉" b := []byte(text) // already UTF-8, no error case len(text) // 18 ← bytes, not characters len([]rune(text)) // 11 ← characters
A Go string is UTF-8 by definition, so []byte(s) is a reinterpretation rather than a conversion — no error path, nothing to handle. This is the cleanest of the three languages we measured: JavaScript needs TextEncoder, because btoa throws on anything above Latin-1, and Python raises TypeError if handed a str.
The one thing that does catch people is len(), which counts bytes. Our eleven-character test string measured 18. Use []rune or utf8.RuneCountInString whenever you mean characters rather than storage.
Decoding a JWT segment
// ✗ fails twice: wrong alphabet AND missing padding base64.StdEncoding.DecodeString(segment) // ✓ JWT segments are base64url without padding base64.RawURLEncoding.DecodeString(segment)
This is where the strictness pays for itself. Both failures are reported with the offending byte offset instead of silently yielding something almost right. A JWT library handles the encoding internally, so you only need this when inspecting a token by hand — which is exactly when you are already debugging and a clear error is worth the most.
Streaming, and the Close that matters
enc := base64.NewEncoder(base64.StdEncoding, out)
io.Copy(enc, in)
if err := enc.Close(); err != nil { ... } // ← requiredNewEncoder and NewDecoder wrap an io.Writer and io.Reader, so a large file never has to sit in memory as one []byte. The part people forget is the Close: the encoder buffers a partial group of up to two bytes and only flushes it, with its padding, when you close it.
Skip that call and the output is silently short by one to three characters — still valid-looking Base64, but it fails to decode at the far end with an error that points at the data rather than at your missingClose. Defer it as you would for any writer, and check the error it returns rather than discarding it, because that final flush is the one write that can fail with nothing else to catch it.
References
Paste a value into the encoder and decoder to see which variant it is. The same decode in Python and JavaScript behaves differently enough to be worth reading before you move values between them. More tools at withuse.io/tools.