Base64 Decoding in Go: A Complete Guide
There is a long string hiding in an API response, and it is pretending to be a value when it is really a file, a token, an image, or a message from a system three years older than yours. Somewhere in your log lines, database rows and JSON payloads, base64 strings show up constantly: the standard 64-letter alphabet, sometimes with a plus and a slash, sometimes with a dash and an underscore, and occasionally two equals signs parked at the end like a signature.
The home page above already explains the format itself: 64 printable characters carrying 6 bits each, four characters per three input bytes, and padding to finish the job. So this article goes straight to the half of the work where the interesting decisions live: opening those strings in Go. The good news is that Go is a wonderful place to do this. One standard library package, zero dependencies, a decoder that is strict by default but forgiving about newlines, and error messages that point at the exact byte that went wrong.
What Ships With Go
Everything you need is already in the standard library. The package is called encoding/base64, its source file still wears a 2009 copyright header from the year the language was born, and there is no extension to enable, no module to fetch, no setting to flip. If go version prints anything on your machine, you already own the whole tool.
As of this writing the newest release is Go 1.27.0, out on August 19, 2026, with the Go 1.26 line (currently 1.26.7) as the other supported track. The base64 API is identical on both, and because of the Go 1 compatibility promise, a program that decodes base64 today will keep doing exactly the same thing on every future release. Get Go itself from the official tarballs on go.dev/dl (something like go1.27.0.linux-amd64.tar.gz, unpacked into /usr/local), from your distribution's package manager (sudo apt install golang-go on Ubuntu-based systems), or via the golang.org/dl wrapper if you like several Go versions side by side.
Once Go is installed, go doc encoding/base64 prints the entire API in a readable column, which is the fastest way to refresh your memory. The only add-on this article uses anywhere is golang.org/x/text for legacy character sets, installed with go get golang.org/x/text. It appears once, in its own section, and the rest is pure standard library.
Your First Decode
Ninety percent of decoding life in Go is one method on the Encoding type:
func (enc *Encoding) DecodeString(s string) ([]byte, error)
Give it a base64 string, and it hands back the bytes it represents, plus an error when the input misbehaves:
package main
import (
"encoding/base64"
"fmt"
)
func main() {
decoded, err := base64.StdEncoding.DecodeString("TWFu")
if err != nil {
fmt.Println("decode failed:", err)
return
}
fmt.Println(string(decoded)) // Man
}
Two things about that signature are worth memorizing. First, the result is a []byte, not a string, because the bytes you unwrap can be perfectly valid base64 and perfectly terrible text: a PNG header, a compressed archive, a binary protocol. Wrap it in string(...) only when you know the payload is text. Second, the method always returns two values. A nil error means the string was clean base64; a non-nil error means the input was broken somewhere, and the byte slice you received may be a partial result instead of an empty one. You will see both sides of that behavior in the errors section below.
Four Decoders, One Question: Which Alphabet?
Go ships four ready-made Encoding values, and picking the right one is the first real decision in every decode. The table below is the seating chart:
| Variable | Alphabet | Padding | Where you will meet it |
|---|---|---|---|
StdEncoding |
A-Z a-z 0-9 + / |
= |
MIME email, data URLs, HTTP Basic auth, PEM files, general JSON |
URLEncoding |
A-Z a-z 0-9 - _ |
= |
URL paths and queries, file names |
RawStdEncoding |
A-Z a-z 0-9 + / |
none | Unpadded standard base64 from compact producers |
RawURLEncoding |
A-Z a-z 0-9 - _ |
none | JWT segments, compact API identifiers |
The fastest way to choose is to look at the data itself. A string containing + or / can only be a standard-alphabet string, so it needs one of the two Std decoders. A string containing - or _ is the URL-safe variant from RFC 4648, so it needs one of the two URL decoders. Then check the tail: trailing = characters mean the padded variant, and their absence means the Raw one. Here is what the wrong choice feels like:
decoded, err := base64.StdEncoding.DecodeString("P29a_")
// err: illegal base64 data at input byte 4
// the underscore is not in the standard alphabet, so the
// decoder stops at the last character it does not recognize
decoded, err = base64.URLEncoding.DecodeString("P29a_")
// decoded is the three bytes 0x3f 0x6f 0x5a, err is nil
If you are decoding data from a producer that defined its own 64-character alphabet, base64.NewEncoding("...64 chars...") builds you a decoder for it. The alphabet must be exactly 64 unique byte values and must not contain the padding character or a newline, because the function panics otherwise. In day-to-day work you will rarely need it, but it is there, and it is the only way to decode a private scheme.
The Tolerance Problem: What Input Does Go Accept?
Every base64 decoder has to make one uncomfortable decision: how much garbage is it willing to swallow? Go's answer is a carefully drawn line. On the forgiving side, the decoder skips carriage returns and line feeds anywhere in the input, so a string that was wrapped across many lines by an email client or a PEM tool decodes without any preprocessing:
decoded, err := base64.StdEncoding.DecodeString("T\nW\nF\r\nu")
// decoded is "Man", err is nil
// every \r and \n in the string was simply ignored
On the strict side, everything else is off limits. A space, a tab, a zero-width character copied from a PDF, a stray colon from a header: the moment the decoder meets a character that is not in the alphabet and is not a newline, it stops and reports the offset. And it keeps whatever it already decoded:
decoded, err := base64.StdEncoding.DecodeString("TWFu junk")
// decoded is "Man" (the part before the space),
// err is: illegal base64 data at input byte 4
That combination surprises people: a failed decode can still hand you a usable half-result. Whether that is a feature or a hazard depends on you; the point is that err == nil is the only condition under which the data is complete.
Padding has its own rules, and they differ between the padded and raw variants. The padded decoders work in groups: a group is either four real characters or two real characters followed by ==. One character by itself is never a complete group, so "T" fails, and "TWF" fails too, because three characters need one padding sign that is missing. The raw decoders drop the padding requirement, but they still cannot accept a length where a group would be missing three of its four characters, so "T" fails there as well while "TW" decodes happily to a single byte.
base64.StdEncoding.DecodeString("T") // error at input byte 0
base64.StdEncoding.DecodeString("TWF") // error at input byte 1
base64.RawStdEncoding.DecodeString("TW") // 1 byte, no error
base64.StdEncoding.DecodeString("TWFu====") // "Man" plus error at byte 4
There is one more mood switch: Strict(), added in Go 1.8. In strict mode the decoder enforces the canonical form from RFC 4648 section 3.5: the unused trailing bits of the final group must be zero. Normal mode does not care, because those bits are simply never used, so "Qm==" decodes to the byte B without complaint. Strict mode calls it a day instead:
decoded, err := base64.StdEncoding.DecodeString("Qm==")
// decoded is "B", err is nil (the trailing bits were dropped)
decoded, err = base64.StdEncoding.Strict().DecodeString("Qm==")
// err is: illegal base64 data at input byte 2
Note that even in strict mode newlines are still skipped, as the documentation points out. Use Strict() when you are speaking a protocol that cares about canonical encoding, or when you want to reject sloppy producers instead of silently absorbing their bits.
Errors That Tell You Where
Every failure in this package arrives as a concrete, inspectable value. When the input contains something the alphabet does not know, or the padding is wrong, the decoder returns a base64.CorruptInputError, and its message includes the byte offset of the problem:
type CorruptInputError int64
func (e CorruptInputError) Error() string {
return "illegal base64 data at input byte " + strconv.FormatInt(int64(e), 10)
}
That offset is the difference between "something failed" and "the 4,102nd character of this 900 kilobyte string is a tab that wandered in from a clipboard". Catch it with the usual Go idiom:
package main
import (
"encoding/base64"
"errors"
"fmt"
)
func main() {
_, err := base64.StdEncoding.DecodeString("TWF$")
var corrupt base64.CorruptInputError
if errors.As(err, &corrupt) {
fmt.Printf("bad byte at offset %d: %v\n", int(corrupt), err)
// bad byte at offset 3: illegal base64 data at input byte 3
return
}
fmt.Println("not a corrupt-input error:", err)
}
Here is the symptom chart for the inputs that confuse people the most:
| Input (StdEncoding) | Result | Why |
|---|---|---|
TWF$ |
error at byte 3 | $ is not in the alphabet |
T |
error at byte 0 | one character is never a complete group |
TWF |
error at byte 1 | three characters need one = that is missing |
TWFu junk |
Man plus error at byte 4 |
space is not a newline, so decoding stops there |
TWFu\t |
Man plus error at byte 4 |
tabs are not skipped, only \r and \n |
T\nW\nF\nu |
Man, no error |
newlines are ignored anywhere |
==== |
error at byte 0 | padding at the start of a group is not valid |
(empty string) |
empty result, no error | zero bytes of base64 decode to zero bytes |
One practical tip: when a decode fails in production, log the offset and a short window around it. Ninety percent of the time the "corrupt" byte is whitespace that the transport, the clipboard, or a PDF viewer sneaked into the string, and the fix is a trim or a strip, not a redesign.
Opening Files
Base64 files are just text files that contain base64, so Go's usual file tools apply. For a file that fits comfortably in memory, read it all and decode the string:
package main
import (
"encoding/base64"
"fmt"
"io"
"os"
)
func main() {
f, err := os.Open("payload.b64")
if err != nil {
fmt.Println("open failed:", err)
return
}
defer f.Close()
raw, err := io.ReadAll(f)
if err != nil {
fmt.Println("read failed:", err)
return
}
decoded, err := base64.StdEncoding.DecodeString(string(raw))
if err != nil {
fmt.Println("decode failed:", err)
return
}
fmt.Println("decoded", len(decoded), "bytes")
}
For large files the better pattern is streaming, and it uses the other half of the package API: NewDecoder wraps any io.Reader in a base64-decoding reader, so you can pipe file to file without ever holding the whole payload in memory:
in, err := os.Open("payload.b64")
if err != nil {
panic(err)
}
defer in.Close()
dec := base64.NewDecoder(base64.StdEncoding, in)
out, err := os.Create("payload.bin")
if err != nil {
panic(err)
}
defer out.Close()
written, err := io.Copy(out, dec)
if err != nil {
panic(err)
}
fmt.Println("wrote", written, "bytes")
There is a middle option when you want to avoid the extra allocation that DecodeString makes: Decode writes into a destination buffer you control. Size it with DecodedLen, which returns the maximum number of output bytes for a given input length:
raw, err := os.ReadFile("payload.b64")
if err != nil {
panic(err)
}
buf := make([]byte, base64.StdEncoding.DecodedLen(len(raw)))
n, err := base64.StdEncoding.Decode(buf, raw)
if err != nil {
panic(err)
}
data := buf[:n] // the actual decoded size
fmt.Println(len(data), "bytes")
Be careful with that last one, though: Decode trusts you to size the buffer. If it is too small, the method does not return an error, it panics with an index out of range. DecodedLen is the number to use, not len(raw).
A Base64 Command Line for Go
Unix systems ship a base64 utility with coreutils, and Go ships no equivalent binary. The idiomatic answer in the Go world is not a package you install but a program you own: a small command-line tool built around encoding/base64, the flag package and standard input. Here is a complete one, about forty lines, that decodes whatever is piped in and writes the raw bytes out:
package main
import (
"encoding/base64"
"flag"
"fmt"
"io"
"os"
)
func main() {
urlSafe := flag.Bool("url", false, "use the URL-safe alphabet")
flag.Parse()
enc := base64.StdEncoding
if *urlSafe {
enc = base64.URLEncoding
}
raw, err := io.ReadAll(os.Stdin)
if err != nil {
fmt.Fprintln(os.Stderr, "read failed:", err)
os.Exit(1)
}
decoded, err := enc.DecodeString(string(raw))
if err != nil {
fmt.Fprintln(os.Stderr, "decode failed:", err)
os.Exit(1)
}
os.Stdout.Write(decoded)
}
Build it once with go build -o b64 . and it becomes a cross-platform decoder you can drop in a Makefile, a CI pipeline or a shell function: printf 'TWFu' | ./b64 prints Man, and ./b64 -url < token.b64 > token.bin unwraps a URL-safe token into a file. Two properties of the design are worth keeping. Because it reads all of stdin before decoding, wrapped input with newlines decodes fine, thanks to the decoder's newline tolerance. And because it exits with status 1 on bad input and writes the complaint to stderr, it behaves like a tool in a pipeline instead of a script that apologizes. That is the whole art of a Go CLI: one package, one flag, standard in, standard out, and an exit code.
URL-Safe Decoding
The URL-safe variant exists because the standard alphabet collides with the grammar of URLs: + is often read as a space in query strings, and / starts a new path segment, so a standard base64 string embedded in a URL has to be percent-escaped character by character, which is slow to parse and ugly to read. RFC 4648's alternate alphabet swaps + and / for - and _, both of which are legal unescaped in URL paths, queries and file names.
In Go the switch is just a different decoder variable. If your data is URL-safe and padded, use URLEncoding; if it is URL-safe and unpadded, use RawURLEncoding. The classic case is an identifier that lives in a URL or a file name:
decoded, err := base64.RawURLEncoding.DecodeString("-w9n")
// decoded is the three bytes 0xfb 0x0f 0x67
// the dash and underscore are part of the URL-safe alphabet,
// so RawURLEncoding handles them where StdEncoding would fail
Where you will meet it in real Go code: JWT segments (covered next), opaque identifiers that systems generate and store in URLs, file names that must not break a web server or a cloud object store, and any API that promised "base64url" in its documentation. One warning: URL-safe is a contract between producer and consumer, not a property of the data. If the string contains a + or a /, it is not URL-safe, full stop, and no amount of retrying with the URL decoder will help. Look at the characters first, then pick the decoder.
Peeking Inside JWTs
A JSON Web Token is three base64url segments separated by dots: a header, a payload of claims, and a signature, with no padding on any of the segments. That makes a JWT one of the most common things you will decode in Go, and the header and payload are readable without any key, which is worth remembering both for debugging and for security reviews:
package main
import (
"encoding/base64"
"fmt"
"log"
"strings"
)
func main() {
token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiR28gRGV2ZWxvcGVyIiwic3ViIjoiMTIzNDU2Nzg5MCJ9.NwJQAKfJpMJQuK0gEECtXtO8cIoFnDp0ovXyl7dY1BQ"
parts := strings.Split(token, ".")
if len(parts) != 3 {
log.Fatal("not a JWT: expected three dot-separated parts")
}
for i, name := range []string{"header", "payload"} {
plain, err := base64.RawURLEncoding.DecodeString(parts[i])
if err != nil {
log.Fatalf("bad %s: %v", name, err)
}
fmt.Printf("%s: %s\n", name, plain)
}
// header: {"alg":"HS256","typ":"JWT"}
// payload: {"name":"Go Developer","sub":"1234567890"}
}
Notice the decoder choice: RawURLEncoding, not StdEncoding. JWT segments use the URL-safe alphabet and carry no padding, and a segment whose length is one or two short of a multiple of four will fail a padded decoder at the very end, which is a confusing error to chase. The signature segment you cannot read without the key, and you should not try to trust anything based on the payload alone, because nothing stops a client from forging the first two segments. When you need verification, use a maintained library. The de facto one is github.com/golang-jwt/jwt/v5 (install it with go get github.com/golang-jwt/jwt/v5):
package main
import (
"fmt"
"log"
"github.com/golang-jwt/jwt/v5"
)
func main() {
secret := []byte("hmac-secret")
token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiR28gRGV2ZWxvcGVyIiwic3ViIjoiMTIzNDU2Nzg5MCJ9.NwJQAKfJpMJQuK0gEECtXtO8cIoFnDp0ovXyl7dY1BQ"
parsed, err := jwt.Parse(token, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return secret, nil
})
if err != nil {
log.Fatal("token rejected:", err)
}
claims, _ := parsed.Claims.(jwt.MapClaims)
fmt.Println("subject:", claims["sub"])
}
Two details from the library are worth knowing. First, the base64url encoding and decoding of the three segments is handled inside, so you never touch encoding/base64 directly when you sign or verify. Second, the v5 library refuses tokens with alg=none unless you explicitly pass its UnsafeAllowNoneSignatureType constant, which protects you from the classic "unsigned token accepted" mistake.
Data URLs
A data URL is a URL whose payload is the data itself. The syntax, from RFC 2397, is data:[mediatype][;base64],data: an optional media type, an optional ;base64 flag, a comma, and then the content. When the ;base64 flag is present the content is standard base64, which is why data URLs and this article share a section. Browsers use them to embed images and fonts directly in HTML and CSS so the page needs one fewer request:
<img src="data:image/png;base64,iVBORw0KGgo=" alt="pixel">
Go's standard library has no data URL helper, but the format is simple enough to parse by hand with strings, which is what most Go programs do:
package main
import (
"encoding/base64"
"fmt"
"strings"
)
func main() {
url := "data:image/png;base64,iVBORw0KGgo="
if !strings.HasPrefix(url, "data:") {
fmt.Println("not a data URL")
return
}
rest := url[len("data:"):]
comma := strings.Index(rest, ",")
if comma == -1 {
fmt.Println("missing comma")
return
}
meta := rest[:comma] // image/png;base64
encoded := rest[comma+1:] // iVBORw0KGgo=
if !strings.HasSuffix(meta, ";base64") {
fmt.Println("this variant is percent-encoded, not base64")
return
}
mediaType := strings.TrimSuffix(meta, ";base64")
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
fmt.Println("decode failed:", err)
return
}
fmt.Println(mediaType, "carries", len(decoded), "bytes")
}
Three pitfalls to keep in mind. First, the ;base64 flag is optional, and without it the payload is percent-encoded ASCII instead of base64, so check the suffix before you call a decoder. Second, when the media type is omitted it defaults to text/plain;charset=US-ASCII, which rarely matters for images but surprises people parsing other content. Third, data URLs are a small-payload trick: the RFC itself says the scheme is only useful for short values, and the 33 percent size expansion of base64 makes a 500 kilobyte logo a 666 kilobyte string glued into your HTML, uncachable and unshareable. Use them for icons and thumbnails, not for videos.
HTTP and API Work
The single most common decode in Go web services is the JSON body field: an upload form, an API response, or a webhook hands you a string that is really a file. Unmarshal into a struct, then decode the field:
package main
import (
"encoding/base64"
"encoding/json"
"fmt"
)
type payload struct {
Avatar string `json:"avatar"`
}
func main() {
body := []byte(`{"avatar": "iVBORw0KGgo="}`)
var p payload
if err := json.Unmarshal(body, &p); err != nil {
fmt.Println("bad JSON:", err)
return
}
img, err := base64.StdEncoding.DecodeString(p.Avatar)
if err != nil {
fmt.Println("bad avatar:", err)
return
}
fmt.Println("avatar is", len(img), "bytes")
}
If your API accepts both standard and URL-safe strings, the pragmatic pattern is to try one decoder, and if it fails with a CorruptInputError near the end, try the other before giving up. Do not do this dance more than once, and never fall back to "strip the equals signs and hope" as a general strategy.
For HTTP Basic authentication, you do not decode anything at all, because Go does it for you. Request.BasicAuth, available since Go 1.4, splits the Authorization header for you and returns the username and password, having already run the standard base64 decoder on the user:pass pair that RFC 2617 defines:
package main
import (
"fmt"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if !ok || user != "alice" || pass != "s3cret" {
w.Header().Set("WWW-Authenticate", `Basic realm="api"`)
w.WriteHeader(http.StatusUnauthorized)
return
}
fmt.Fprintln(w, "hello", user)
})
http.ListenAndServe(":8080", mux)
}
Remember that Basic auth is authentication, not protection: the header is base64, not encrypted, so it must only travel over HTTPS. If you are the client, the mirror call is req.SetBasicAuth(user, pass), which builds the same header for you with the standard encoder.
One defensive habit for API handlers: limit the body before you decode it, with http.MaxBytesReader or an equivalent length check. A base64 string decodes to roughly three quarters of its own length, so a body limit of N bytes keeps the decoded result under N bytes, and the memory stays bounded no matter what a malicious client posts. Decoding an unbounded body is a classic memory exhaustion vector, because the attacker controls how many megabytes of text they can turn into binary.
Legacy Character Sets
Decoding base64 gives you bytes, and in modern systems those bytes are almost always UTF-8, in which case string(decoded) is the entire story. But base64 is an old format and a lot of it was produced by systems that used Windows-1252, ISO-8859-1, Shift JIS or some other single-byte or double-byte legacy charset. If the producer did that, the bytes you decode are not valid UTF-8, and Go will not pretend they are: it will show you replacement characters wherever a sequence is broken.
Go's answer is the golang.org/x/text module, which turns legacy-encoded bytes into UTF-8 (and back again) for the common charsets. The conversion slot is right after the decode, and it takes one function call:
package main
import (
"encoding/base64"
"fmt"
"golang.org/x/text/encoding/charmap"
"golang.org/x/text/transform"
)
func main() {
// "Café" stored as Windows-1252 by a legacy tool,
// then base64-encoded for transport
encoded := "Q2Fm6Q=="
raw, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
fmt.Println("decode failed:", err)
return
}
utf8, _, err := transform.Bytes(charmap.Windows1252.NewDecoder(), raw)
if err != nil {
fmt.Println("charset conversion failed:", err)
return
}
fmt.Println(string(utf8)) // Café
}
The module has one subpackage per charset family: charmap for the Windows and ISO single-byte tables, japanese for Shift JIS and EUC-JP, korean for EUC-KR, simplifiedchinese for GB18030, and traditionalchinese for Big5. The rule of thumb is to convert only when you actually know the producer's charset, because converting UTF-8 bytes a second time does not fail loudly, it just mangles the text. When in doubt, treat the payload as bytes and let the downstream consumer decide.
Streaming and Chunked Decodes
You saw NewDecoder in the files section; here is what makes it worth a section of its own. It is a true streaming adapter: it pulls from the underlying reader only as much as needed, decodes in place, and returns a CorruptInputError the moment the stream goes bad. The whole stream can be terabytes; the memory you hold is your buffer and the output you write. The two common consumer patterns are io.ReadAll for small streams and io.Copy for everything else:
small, err := io.ReadAll(base64.NewDecoder(base64.StdEncoding, r))
// fine for a config blob or a small attachment
w, err := io.Copy(out, base64.NewDecoder(base64.StdEncoding, r))
// fine for a video, a tarball, or a restore job
Since Go 1.22 the package also has AppendDecode, which decodes into a buffer you reuse instead of allocating a fresh slice per call. It is the tool for hot paths that decode many chunks in a loop, such as a line processor or a protocol decoder:
var buf []byte
for _, chunk := range chunks {
buf, err = base64.StdEncoding.AppendDecode(buf, chunk)
if err != nil {
return err
}
process(buf)
}
The method appends the decoded chunk to whatever buf already holds and returns the extended slice, growing the backing array as needed. In steady state, where the buffer has already grown to the right size, it performs zero allocations per chunk, which shows up clearly in a benchmark. If your workload is "decode once, rarely", DecodeString is the simpler choice; if it is "decode thousands of times in a tight loop", AppendDecode is the one to reach for.
Keeping It Safe
A few security notes that are specific to how Go programs actually use this package. First, base64 is encoding, not encryption. A base64 string is readable by anyone with a web browser's developer tools, so "we base64 the password before sending it" is not a security measure, it is a transport convenience. The confidentiality has to come from TLS, not from the alphabet.
Second, bound your inputs. The decoded size of a base64 string is at most DecodedLen of its length, so check that number against a limit before you allocate, and wrap request bodies with a size cap before anything touches a decoder. Both checks are one line each, and together they turn an unbounded decode into a bounded one.
Third, decide your stance on sloppy input. Normal mode silently drops the unused trailing bits of the final group, which means two different strings can decode to the same bytes. For most data that does not matter. For anything that is part of a protocol, a signed message, or a value that gets compared or stored, Strict() is the conservative choice, because it makes the canonical form the only accepted form.
Fourth, be careful where decoded bytes go. If a decoded value becomes a file name, a path, a SQL fragment or a command argument, the base64 layer did not protect you from anything: the bytes are now your program's untrusted input, and the usual sanitization rules apply exactly as they would for any other user data.
How Fast Is the Decoder?
Base64 in Go is fast, and it stays fast on large data because the implementation is a simple table lookup loop with no reflection and no allocation per character. On a recent desktop CPU running Go 1.26, a 500-byte string decodes in roughly a quarter of a microsecond with one allocation, which works out to on the order of two gigabytes per second. A megabyte of base64 decodes in well under a millisecond; a gigabyte in well under a second. The numbers move with the hardware, but the shape does not: base64 decode is almost never the bottleneck, the network or the disk around it usually is.
If you are in a hot loop, the allocation profile is the thing to watch. DecodeString allocates the result slice on every call. Decode with a pre-sized destination and AppendDecode with a reused buffer both avoid that allocation entirely in steady state. For a decode that happens a few times a request, none of this matters; for a decode that happens a few million times a second, it is the difference between a flat memory profile and a churning garbage collector.
A Short History of the Package
The base64 package is one of the oldest parts of the Go standard library. The source file's copyright header reads 2009, the year the language was created, and the package has been part of the standard library since the very first stable release, Go 1.0, in February 2012. That means the DecodeString you call today is the same API, with the same behavior, that Go programs have called for over a decade.
The growth since then has been modest and useful. Go 1.5 in August 2015 added the unpadded RawStdEncoding and RawURLEncoding values, which opened the door to JWT-style compact strings. Go 1.8 in February 2017 added Strict(), giving protocols a way to demand canonical input. Go 1.22 in February 2024 added AppendDecode and AppendEncode to the whole family of base encodings, and tightened WithPadding to reject nonsense arguments. And as of August 2026, with Go 1.27.0 as the newest release and Go 1.26 as the other supported line, the API is exactly the one described in this article: four ready-made encodings, a stream decoder, a strict mode, and an append family for performance.
The deeper fact is the compatibility promise. Go 1's guarantee means the package will keep accepting and rejecting the same inputs forever, so a decoder you write this year against a data format produced in 2015 will keep working. For a format this old and this boring, that is the best news there is.
Things That Will Surprise You
After a while in Go you stop being surprised by base64, but the first few times, a few of these facts land hard, so here they are:
- The decoder skips
\rand\nanywhere in the input, but not a space, not a tab, not a zero-width space. The leniency is deliberate, it exists to make MIME-wrapped input work, and it stops exactly where the spec stops. - A failed decode can still return real data. The partial result is everything decoded before the bad byte, and the error arrives alongside it, not instead of it.
CorruptInputErroris literally just anint64with a method attached. The "error" is the offset, and the message is built on demand.DecodeandEncodeboth trust you to size their destination buffers. Give them a buffer that is too small and you do not get an error, you get a panic.- A single character is not valid input for any of the four built-in encodings. One base64 character carries six bits, and a byte needs eight, so there is no complete group in one character, padded or not.
- As of August 2026, more than 244,000 public packages on pkg.go.dev list
encoding/base64among their imports. It is quietly one of the most depended-on packages in the entire ecosystem.
Where Decodes Go Wrong
These are the decoding mistakes that keep showing up in Go codebases, in roughly the order they appear in support threads:
- Picking
StdEncodingfor URL-safe data (or the reverse). The symptom is an error at the first-,_,+or/, and the fix is to look at the string before you pick the decoder. - Pasting a string from a terminal, an email or a PDF, which sneaks in spaces, tabs or line-ending artifacts. Go skips real newlines, but a space mid-string is a corrupt byte, and the offset in the error will point right at it.
- Forgetting that the result is a
[]byte. Printing it raw gives you a number, and feeding it to a function that expects a string needs astring(...)conversion. - Checking the error but then using the partial data anyway. The half-decoded prefix is real, but it is not the payload, and code that treats it as such fails in production with data that is exactly half the right length.
- Sizing the
Decodebuffer withlen(src)instead ofDecodedLen(len(src)). The first size is wrong in the other direction you hope, and the panic it triggers only happens on large inputs, which makes it a favorite of staging environments. - Assuming JWT segments carry padding. They do not, and a padded decoder fails at the last character with an error that reads like a mystery. Use
RawURLEncoding. - Believing that all whitespace is skipped. It is not. Only the two newline characters are, and "whitespace" in the clipboard is a much larger family than that.
- Double-decoding, or failing to decode twice, when the value is base64 of base64 (a file that was attached to an email that was itself attached). The check is one round trip: decode once, see whether the result still looks like base64, and only then decode again.
The Other Half of the Job
That is the whole decoding side of the story: one package, four ready-made decoders, a stream decoder for big data, a strict mode for picky protocols, and error messages that tell you the byte where things went wrong. Learn the tolerance rules, pick your decoder by looking at the characters, bound your inputs, and base64 in Go becomes the boring, predictable, zero-dependency utility it was designed to be.
When the job flips, and your Go program needs to produce base64 strings instead of opening them, the related article on Base64 encoding in Go covers that side in detail: the encoder's one-method API, the Close call that quietly swallows your last two bytes, line wrapping for MIME, and how the four encodings map onto the channels they travel through.
Last updated: 2026-08-29
Related article: Base64 Encoding in Go: A Complete Guide