Base64 Encoding in Rust: A Complete Guide
You are about to send some bytes through a text-only door, and the price of entry is a string of letters, digits, plus signs and slashes that is roughly a third longer than what you started with. Welcome to Base64, the toll booth of the internet. The home page of this site explains the format in full depth, so only the shape needs restating here: Base64 writes three input bytes as four characters drawn from a 64 symbol alphabet, and a tail of one or two = characters tells the reader where the real data ended. That four-for-three trade is the entire economy of the format, and this guide is about doing it well in Rust.
The first thing to know is that the Rust standard library will not do it for you. There is no base64_encode() hiding in std, and no use std::... that changes your mind. The ecosystem settled on a single crate simply named base64, and it has become load bearing: version 0.23.1 shipped on August 4, 2026, the crate has published 45 versions since December 2015, and its download counter sits near 1.5 billion. Every encoding example below uses that one crate, plus two small companions for line wrapping and PEM armor.
The Toolchain and the Crate
First the toolchain, one command per world:
# Debian / Ubuntu
sudo apt install rustc cargo
# or the official installer, which sets up rustup and cargo
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Then the crate, inside any cargo project:
cargo new my-app
cd my-app
cargo add base64
That single line is the entire installation, and it pulls in exactly zero dependencies. The crate ships with three optional features you should know about: std (on by default; gives you std::io streaming, the standard Error impls and heap allocation), alloc (the allocating APIs for embedded no_std builds) and simd-unsafe (on by default; the SIMD engines, which appear a few sections down). The minimum supported Rust version is 1.71.0, so anything recent will run it. Around it sit the companions for jobs the core deliberately does not do:
- line-wrap (version 0.2) inserts the 76 or 64 character line breaks that MIME and PEM demand; the
base64crate itself refuses to wrap, on purpose, as you will see. - pem (version 4) builds and parses
-----BEGIN ...-----blocks for certificates and keys; it depends onbase64internally and adds the armor and the wrapping. - base64ct (version 1.8) is the constant-time decoder from the RustCrypto project, for when the reading side of a round trip is the sensitive half.
- base64-turbo (version 0.3) is a newer high-throughput codec that peaks past 100 GiB/s on modern hardware.
One Encode, Four Characters
The smallest possible ceremony looks like this, and it already proves the whole round trip:
use base64::prelude::*;
fn main() {
let packed = BASE64_STANDARD.encode("Hello, world!");
println!("{packed}");
// SGVsbG8sIHdvcmxkIQ==
let back = BASE64_STANDARD.decode(packed).unwrap();
println!("{}", String::from_utf8(back).unwrap());
// Hello, world!
}
Two things are worth noticing. The prelude module quietly hands you two things at once: the BASE64_STANDARD engine and the Engine trait whose methods you are calling, which is why a bare use base64::prelude::*; is all this example needs. And encode() takes anything that can be read as bytes, thanks to the AsRef<[u8]> bound: a &str, a &[u8] literal, a Vec<u8>, you name it. The decoding half of the example is just there to keep an eye on the encoder, because the opposite direction gets its own complete guide over at the sister site. If you want the crate's own smoke test, its documentation encodes asdf and gets YXNkZg== back; same alphabet, same math.
The Exact Price of Every Byte
Every Base64 encoder in existence bills the same tax, and once you can see the math, you can budget for it. Each output character carries 6 bits, each input byte carries 8, and the smallest pile that is both is 24 bits: exactly 3 bytes in, exactly 4 characters out. That ratio is the whole show, so a 3 kilobyte file becomes 4 kilobytes and a 10 megabyte upload becomes 13.3. The padding is the rounding error made visible: when the input is not a multiple of 3 bytes, the final group has spare capacity, and the encoder fills it with = so the output length stays a multiple of 4. Here is the truth table from RFC 4648, which the standard engine reproduces exactly:
| Input | Length mod 3 | Encoded | Output length |
|---|---|---|---|
"" (empty) |
0 | "" (empty) |
0 |
f |
1 | Zg== |
4 |
fo |
2 | Zm8= |
4 |
foo |
0 | Zm9v |
4 |
foobar |
0 | Zm9vYmFy |
8 |
use base64::prelude::*;
let words: [&[u8]; 4] = [b"", b"f", b"fo", b"foo"];
for input in words {
println!("{:?} -> {:?}", String::from_utf8_lossy(input), BASE64_STANDARD.encode(input));
}
// "" -> ""
// "f" -> "Zg=="
// "fo" -> "Zm8="
// "foo" -> "Zm9v"
Read that first row twice, because it is the one everyone gets wrong in their head: the empty input encodes to the empty string, not to AA==. The string AA== is the encoding of exactly one byte, a NUL, which is a genuinely different payload. And when you need to size a buffer before encoding, the crate hands you the math as a const fn, so you can even size arrays at compile time:
let padded = base64::encoded_len(15, true).unwrap();
let slim = base64::encoded_len(15, false).unwrap();
println!("{padded} / {slim}"); // 20 / 20
println!("{:?}", base64::encoded_len(13, true)); // Some(20)
println!("{:?}", base64::encoded_len(13, false)); // Some(18)
println!("{:?}", base64::encoded_len(14, false)); // Some(19)
println!("{:?}", base64::encoded_len(100, false)); // Some(134)
Watch the 13 and 14 byte rows, because they are the ones that trip up back-of-envelope math: 13 bytes need 18 unpadded characters but 20 padded, while 14 bytes need 19 and 20. The function returns an Option, which is None only when the length math would overflow, so an unwrap() is safe for any input that could actually exist in memory. For the email-shaped world the tax has a surcharge: MIME wraps lines at 76 characters, and the old rule of thumb is that wrapped Base64 costs about 1.37 times the original size, plus the header overhead. The crate's own FAQ has a less polite opinion about the padding itself: the = bytes "do not affect decoding other than to provide an opportunity to say 'that padding is incorrect'", and "exabytes of storage and transfer have no doubt been wasted on pointless = bytes".
Padding: A Decision About the Reader
In base64 0.23 there is no bare encode function. You call a method on an Engine, and an engine is a policy: which alphabet to write, and which padding to add. The presets live in base64::engine::general_purpose, with the four popular ones re-exported into the prelude:
| Engine | Alphabet | Adds padding | Best for |
|---|---|---|---|
STANDARD / BASE64_STANDARD |
+ / |
yes | everything, the default |
STANDARD_NO_PAD / BASE64_STANDARD_NO_PAD |
+ / |
no | slim payloads you also consume |
URL_SAFE / BASE64_URL_SAFE |
- _ |
yes | URL content that still wants padding |
URL_SAFE_NO_PAD / BASE64_URL_SAFE_NO_PAD |
- _ |
no | JWTs, URLs, object ids |
Encoding without padding is not a hack the crate tolerates; it is a first-class position, with preconfigured NO_PAD and PAD config constants alongside the *_INDIFFERENT siblings added in 0.23.0. If the presets do not fit, you build your own engine from an Alphabet and a GeneralPurposeConfig with one dial, and because engines are cheap to construct, you store the result in a const instead of rebuilding it per request:
use base64::engine::general_purpose::{GeneralPurpose, GeneralPurposeConfig};
use base64::prelude::*;
const SLIM: GeneralPurpose = GeneralPurpose::new(
&base64::alphabet::STANDARD,
GeneralPurposeConfig::new().with_encode_padding(false),
);
fn main() {
println!("{}", SLIM.encode("fo")); // Zm8
println!("{}", BASE64_STANDARD.encode("fo")); // Zm8=
}
Now the decision becomes a decision about other people's decoders, which is never purely aesthetic. The strictness rules on the decode side come from DecodePaddingMode, and the table below answers "can the other side read what I wrote?":
| You encode with | A strict STANDARD decoder |
A STANDARD_NO_PAD decoder |
An INDIFFERENT decoder |
|---|---|---|---|
STANDARD (padded) |
reads it | refuses the = |
reads it |
STANDARD_NO_PAD |
refuses: padding missing | reads it | reads it |
URL_SAFE_NO_PAD |
refuses: wrong alphabet | refuses: wrong alphabet | reads it only with the URL alphabet |
The practical rules fall out of that table. If you control both ends, pick one engine and use it everywhere, and prefer no padding to save bytes. If you consume data from the outside world, your decoder gets a vote in which engine you should emit: a stock STANDARD decoder needs your padding, while a STANDARD_PAD_INDIFFERENT decoder accepts both. And there is a security flavor to the choice too. Allowing both padded and unpadded spellings of the same payload makes Base64 malleable; the 2022 paper "Base64 Malleability in Practice" (Chatzigiannis and Chalkias, ePrint 2022/361), which the crate's own documentation links to, shows why. A protocol where the same data can be written two different ways is a protocol with a surprise in store for whoever compares strings after decoding, so when your format defines one canonical spelling, enforce it at the boundary.
Base64url for Tokens and Links
Standard Base64's last two alphabet letters are + and /, and in a URL those are two of the most expensive characters in the language: plus becomes %2B, slash becomes %2F, and padding becomes %3D. RFC 4648 section 5 fixes this with the URL and filename safe alphabet, which swaps the two troublemakers for - and _ and usually skips the padding too. The engines make the distinction unmissable:
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
let packed = URL_SAFE_NO_PAD.encode(b"\xfb\xef\xbe");
println!("{packed}"); // ----
let back = URL_SAFE_NO_PAD.decode(packed).unwrap();
println!("{back:02x?}"); // [fb, ef, be]
Three bytes of the nastiest possible input become a four character string you can paste into a URL, a filename, a cookie or a database key without a single percent escape. This is the alphabet that JSON Web Tokens live in: a JWT is three base64url parts joined by dots, and minting one with the jsonwebtoken crate (version 11 in 2026) looks like this:
use serde::Serialize;
use jsonwebtoken::{EncodingKey, Header, encode};
#[derive(Debug, Serialize)]
struct Claims {
sub: String,
company: String,
exp: u64,
}
let key = b"secret";
let my_claims = Claims {
sub: "b@b.com".to_owned(),
company: "ACME".to_owned(),
exp: 19_000_000_000, // well in the future
};
let token = encode(&Header::default(), &my_claims, &EncodingKey::from_secret(key)).unwrap();
println!("{token}");
// eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJiQGIuY29t...
Version 11 has one setup requirement that bites newcomers: the crate needs exactly one of the rust_crypto or aws_lc_rs features enabled in Cargo.toml, and it panics at startup if neither is on. Note the exp claim in the struct: the crate's validation treats it as required by default, so real tokens carry one anyway, and the base64url alphabet in the token is entirely the library's business. If you are only inspecting tokens rather than minting them, the sister article shows the five line peek. And a reminder of the golden rule, which applies with special force to tokens: a JWT's three parts are all readable without a key. Base64 is a window seat, not a lock.
Text In, Bytes Out
Encoders do not read minds, so "encode this string" always means "encode the UTF-8 bytes of this string" in Rust, because that is what str::as_bytes() hands over. The good news is that the modern web is almost entirely UTF-8, so the honest path is short and happy:
use base64::prelude::*;
let text = "café";
let packed = BASE64_STANDARD.encode(text.as_bytes());
println!("{packed}"); // Y2Fmw6k=
The multibyte cases all behave:
| Original text | Base64 | Round trip |
|---|---|---|
café |
Y2Fmw6k= |
clean |
日本語 |
5pel5pys6Kqe |
clean |
😀 |
8J+YgA== |
clean |
π ≈ 3.14159 |
z4Ag4omIIDMuMTQxNTk= |
clean |
The one real decision is which bytes you start from. If the data arrives as bytes and not as text, a file read from disk or a buffer from a network call, skip the string entirely and encode the Vec<u8> directly; that is also the only correct answer for non-UTF-8 payloads like a PNG or a protobuf. Drop any image next to your code and point the read at it:
use base64::prelude::*;
let file_bytes = std::fs::read("sprite.png").unwrap();
let size = file_bytes.len();
let packed = BASE64_STANDARD.encode(file_bytes);
println!("{size} bytes -> {} base64 chars", packed.len());
// every encoded PNG starts with iVBORw0K
assert!(packed.starts_with("iVBORw0K"));
That last assertion is a free sanity check and one of the most recognizable prefixes on the internet. And if you ever encode the same logical text through two different charsets, or encode bytes you misread as a different charset, the round trip will come back as mojibake with a straight face. The encoder never lies; it just encodes whatever bytes you give it, which is both its greatest strength and its only trap.
When a Format Wants Lines
The base64 crate deliberately does not insert line breaks, and it is not the first time it has made that call. Version 0.5.0 shipped built-in MIME line wrapping with configurable line endings, and version 0.10.0 removed it, the library deciding that wrapping was too opinionated for a general crate and complicated the no_std story. If a format demands lines, the line-wrap crate exists exactly for that. Its one function, line_wrap(), takes your pre-allocated buffer, the input length, the column limit and the line ending, and returns the number of line-ending bytes it inserted:
use base64::prelude::*;
let data = BASE64_STANDARD.encode(vec![b'a'; 300]); // 400 chars
let mut buf = vec![0u8; data.len() + 16];
buf[..data.len()].copy_from_slice(data.as_bytes());
let endings = line_wrap::line_wrap(&mut buf, data.len(), 76, &line_wrap::crlf());
buf.truncate(data.len() + endings);
let wrapped = String::from_utf8(buf).unwrap();
println!("{} chars in, {} bytes out, {} line endings", data.len(), wrapped.len(), endings);
// 400 chars in, 410 bytes out, 10 line endings (five CRLF pairs)
Pre-size the buffer with room for the endings, call the function, and truncate to the reported total; the five CRLF pairs are the price of MIME's 76 column rule. For PEM, swap the limit and the ending, 64 columns and line_wrap::lf(), and you have the armor's body text. Then the pem crate adds the banners in one call:
let pem_block = pem::encode(&pem::Pem::new("CERTIFICATE", b"0123456789abcdef"));
println!("{pem_block}");
// -----BEGIN CERTIFICATE-----
// MDEyMzQ1Njc4OWFiY2RlZg==
// -----END CERTIFICATE-----
let back = pem::parse(pem_block).unwrap();
println!("{}: {} bytes", back.tag(), back.contents().len());
// CERTIFICATE: 16 bytes
// and Unix-style line endings, if the consumer is picky
let lf_block = pem::encode_config(
&pem::Pem::new("KEY", b"0123456789abcdef"),
pem::EncodeConfig::new().set_line_ending(pem::LineEnding::LF),
);
By default, pem::encode uses CRLF, the historic PEM convention; the set_line_ending builder switches to LF for the tools that expect it. Notice what the pem crate does not do: it never calls a base64 function you can see, because the encoding is its internal business. When a format wants lines, the architecture is one crate per job.
Streaming in Constant Space
For data too big to keep in one variable, the crate answers with the same streaming philosophy as the rest of Rust's io: the write::EncoderWriter wraps any writer and base64-encodes everything you write to it, in constant space. The complete ritual for a buffer looks like this, and the star of the section is the finish() call:
use std::io::Write;
use base64::prelude::*;
use base64::write::EncoderWriter;
fn main() {
let mut encoder = EncoderWriter::new(Vec::new(), &BASE64_STANDARD);
encoder.write_all(b"the quick brown fox jumps over the lazy dog").unwrap();
let packed = encoder.finish().unwrap();
println!("{}", String::from_utf8(packed).unwrap());
// dGhlIHF1aWNrIGJyb3duIGZveCBqdW1wcyBvdmVyIHRoZSBsYXp5IGRvZw==
}
Why is finish() the star? Because it is the one call that flushes the final partial group and adds the padding, and the encoder has a sibling method that does not. The crate's own documentation says it plainly: finish() "encodes any leftover input bytes and adds padding if appropriate. It's called automatically when deallocated (see the Drop implementation), but any error that occurs when invoking the underlying writer will be suppressed. If you want to handle such errors, call finish() yourself." The Drop implementation behaves like BufWriter: it flushes, but it ignores errors during drop. So the last partial group is not lost, but "it probably worked" is not a shipping strategy, because the write error it would have told you about is gone.
The same stream works one level removed through io::copy when you want the whole pipeline in one call, and there is a bonus wrapper for the "I just need this inside a format string" moments:
use std::io;
use base64::prelude::*;
use base64::write::EncoderWriter;
let file = b"the quick brown fox jumps over the lazy dog".to_vec();
let mut cursor = io::Cursor::new(file);
let mut encoder = EncoderWriter::new(Vec::new(), &BASE64_STANDARD);
io::copy(&mut cursor, &mut encoder).unwrap();
let packed = encoder.finish().unwrap();
println!("{}", String::from_utf8(packed).unwrap());
use base64::display::Base64Display;
use base64::prelude::*;
let value = Base64Display::new(b"\0\x01\x02\x03", &BASE64_STANDARD);
println!("base64: {value}"); // base64: AAECAw==
That Base64Display wrapper is a little gem: it formats bytes as Base64 inside any format string without a single heap allocation, which makes log lines and debug output suddenly pleasant.
Allocation, and Its Absence
The convenient method allocates, and for most of your life that is the right trade. But the Engine trait exposes three flavors of encode, and the table below is the whole decision matrix:
| Method | Output | Allocates |
|---|---|---|
encode() |
a new String |
always |
encode_string() |
appends to your String |
only if it must grow |
encode_slice() |
writes into your &[u8] |
never |
use base64::prelude::*;
let input = b"Hello, world!";
let mut buf = vec![0u8; base64::encoded_len(input.len(), true).unwrap()];
let written = BASE64_STANDARD.encode_slice(input, &mut buf).unwrap();
buf.truncate(written);
println!("{}", std::str::from_utf8(&buf).unwrap()); // SGVsbG8sIHdvcmxkIQ==
// or keep the buffer on the stack entirely
let mut stack = [0u8; 24];
let n = BASE64_STANDARD.encode_slice(b"abc 123", &mut stack).unwrap();
println!("{}", String::from_utf8(stack[..n].to_vec()).unwrap()); // YWJjIDEyMw==
// and if you sized it wrong, you get an error, not a buffer overflow
let mut tiny = [0u8; 5];
println!("{:?}", BASE64_STANDARD.encode_slice(input, &mut tiny));
// Err(OutputSliceTooSmall)
Size the buffer with encoded_len(), write with encode_slice(), and if you got the size wrong you get a clean EncodeSliceError::OutputSliceTooSmall instead of undefined behavior, which in a systems language is the difference between a boring afternoon and a long one. For embedded work the same functions exist behind the alloc feature, so you can keep the API and drop the heap.
Speed: The SIMD Engines
Version 0.23.0, the one that shipped in July 2026, brought the headline feature: SIMD-accelerated engines for the standard and URL-safe alphabets. There are three of them, and they split by how hard they trust your hardware:
| Engine | Detects at runtime | Works in no_std |
|---|---|---|
Simd |
yes, picks AVX2 or NEON, falls back to the scalar engine | no, needs std for detection |
Avx2 |
no, assumes the CPU has AVX2 | yes, on x86_64 targets |
Neon |
no, assumes the CPU has NEON | yes, on aarch64 targets |
use base64::engine::general_purpose::GeneralPurposeConfig;
use base64::engine::{Avx2, Simd};
use base64::Engine;
let turbo = Simd::standard(GeneralPurposeConfig::new());
println!("{}", turbo.encode("simd works!"));
// c2ltZCB3b3JrcyE=
if let Some(fixed) = Avx2::standard(GeneralPurposeConfig::new()) {
println!("{}", fixed.encode("hello avx2")); // aGVsbG8gYXZ4Mg==
}
The Simd constructor does its CPU detection once and returns the best kernel it finds, or the scalar engine if none apply, so build it once in a const or at startup and reuse it; on capable hardware it is several times faster than the scalar path for both encoding and decoding. One honest footnote: the SIMD path is the only place in the crate that touches unsafe, which is why the feature is called simd-unsafe. Turn the feature off and the whole crate is #![forbid(unsafe_code)] again, with the scalar engine still doing honest work. If raw throughput is the entire point, the base64-turbo crate pushes the envelope further, peaking past 100 GiB/s with AVX512, AVX2 and NEON kernels behind runtime detection, and a 100% safe scalar fallback on everything else. The base64 crate is dual-licensed MIT/Apache-2.0, so all of this is free, including the speed.
Four More Alphabets
The RFC alphabet is the default, but the base64 crate ships four more, each one a small monument to some real protocol that needed its own twist:
| Alphabet | The twist | Who uses it | abc 123 encodes to |
|---|---|---|---|
alphabet::CRYPT |
./ come first, then digits and letters, no padding |
classic Unix crypt(3) password hashes | MK7X612mAk |
alphabet::BCRYPT |
./ first, then letters, then digits |
bcrypt password hashes | WUHhGBCwKu |
alphabet::IMAP_MUTF7 |
a comma stands in for the slash, no padding | IMAP's modified UTF-7 mailbox names | YWJjIDEyMw |
alphabet::BIN_HEX |
a punctuation-heavy alphabet that skips confusable letters | BinHex 4, the old Macintosh file wrapper | B@*M)$%b-` |
use base64::engine::general_purpose::{GeneralPurpose, NO_PAD};
use base64::Engine;
let crypt = GeneralPurpose::new(&base64::alphabet::CRYPT, NO_PAD);
println!("{}", crypt.encode(b"abc 123")); // MK7X612mAk
let bcrypt = GeneralPurpose::new(&base64::alphabet::BCRYPT, NO_PAD);
println!("{}", bcrypt.encode(b"abc 123")); // WUHhGBCwKu
let imap = GeneralPurpose::new(&base64::alphabet::IMAP_MUTF7, NO_PAD);
println!("{}", imap.encode(b"abc 123")); // YWJjIDEyMw
Same input, three different outputs, all valid Base64 in their own dialect. The crypt alphabet is the one with a genuine superpower: because its symbols are ordered to match the bit patterns, sorting the encoded strings gives you the same order as sorting the original bytes, which is exactly why the GEDCOM genealogy standard still uses it for multimedia fields decades later. And if the dialect you need is not in the crate, you can define it with a 64 character string, because Alphabet::new() builds the encode and decode tables for you:
use base64::alphabet::Alphabet;
use base64::engine::general_purpose::{GeneralPurpose, PAD};
use base64::Engine;
// a bizarro-world base64: +/ at the front instead of the end
let alphabet = Alphabet::new(
"+/ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789",
).expect("a valid 64 char alphabet");
let bizarro = GeneralPurpose::new(&alphabet, PAD);
println!("{}", bizarro.encode(b"hello 99")); // YETqZE6eMRi=
// while the standard engine says:
println!("{}", base64::prelude::BASE64_STANDARD.encode(b"hello 99"));
// aGVsbG8gOTk=
One warning about the custom alphabet route: the moment you invent a dialect, you become the only person on earth who can read your data, so do it only when a protocol demands it, and write a comment saying which one.
Where Encoders Work
Base64 encoding shows up in Rust projects in a predictable cast of situations:
- File uploads in JSON APIs, where the file is a byte field wearing a text costume, the most common use by a wide margin.
- Data URIs in HTML and CSS, the
data:image/png;base64,...kind, wonderful for tiny icons, questionable for hero images. - JWTs and OAuth, where base64url is the dialect and the
jsonwebtokencrate is the tool. - PEM blocks for certificates and keys, the
-----BEGIN CERTIFICATE-----sections that wrap Base64 at 64 characters per line. - Binary in XML and config files, the
<data encoding="base64">pattern you still find in exported bookmarks and settings dumps. - LDAP and LDIF files, which use Base64 to keep binary attribute values on one line.
- QR code payloads and clipboard handoffs, where text survives the trip and binary does not.
- HTTP Basic auth headers, where
Basic TWFuOnBhc3M=is a credential pair, and a reminder that this is a packing problem, not a hiding one.
And the golden rule that governs all of it: Base64 is packing tape, not a lock. It is not encryption and it is not compression, it is the opposite of compression, and anyone with this article can reverse everything it does in one line. Encode freely, but never encode a password, an API key or a secret and call it protected. If it must be hidden, use real encryption, and if it is large, consider whether a multipart upload would simply have been cheaper than the tax.
A Decade of Small Steps
The format is older than the web. In 1987, the Privacy Enhanced Mail protocol (RFC 989) needed to carry binary data over 7 bit mail channels, and it standardized this encoding with exactly 64 character lines. Every -----BEGIN CERTIFICATE----- block on the internet is a descendant of that decision, which is why PEM files still wrap at 64 today. In 1996 the MIME spec (RFC 2045) adopted the scheme, named it "base64" after its 64 character alphabet, and moved the wrap to 76 characters. Before all of that, Unix boxes shipped uuencode and Macs shipped BinHex, each with their own alphabet, and both still surface in old systems like fossils with file headers. In 2006, RFC 4648 became the standard everyone quotes, with the alphabet tables, the base64url variant, and the canonical encoding rules that every engine in this article implements. Its section 3.5 requires encoders to set the unused trailing bits to zero, and the crate does so; if your payload later trips a strict decoder's InvalidLastSymbol check, the corruption happened upstream.
The crate's own history rhymes. It appeared on crates.io in December 2015, and version 0.5.0 proudly added MIME line wrapping with configurable line endings. Then version 0.10.0 in 2018 removed the wrapping and the whitespace handling, the library deciding that a general-purpose crate should encode and leave the poetry to the application layer; the same release added the streaming EncoderWriter. Version 0.20.0 in 2022 introduced the engine abstraction and made canonical padding the default, and 0.21.0 deprecated the old free functions in favor of engine methods, with the compiler note "Use Engine::encode" (they still work, which is why a lot of legacy code compiles happily). In 2024, version 0.22.0 sharpened the error semantics and sped decoding up 5 to 10 percent. And in July 2026, version 0.23.0 arrived with the SIMD engines, custom padding symbols, a clearer error message and the MSRV bump to 1.71, with the 0.23.1 patch on August 4 fixing the test suite for non-SIMD architectures.
Things Worth Smiling At
Because a complete guide should end on a smile:
- The word "base64" encodes to
YmFzZTY0. A format describing itself is the technical equivalent of a mirror that speaks in Morse. - The empty string encodes to the empty string. Nothing is the only input that costs nothing, which is a kind of tax exemption.
AA==is not the encoding of nothing; it is the encoding of one NUL byte. In Base64, "nothing" and "a zero" are different creatures, and decoders tell them apart.- Every Base64-encoded PNG starts with
iVBORw0K. That is the PNG magic number in its packing tape, one of the most recognizable prefixes on the internet. - In a URL, standard Base64 characters need escape costumes: plus becomes
%2B, slash becomes%2F, and padding becomes%3D. Base64url exists so the characters can wear their own faces. - YouTube video IDs are base64url without padding: eleven bytes of ID become a short string you can paste anywhere. One of the most visible uses of the no-padding mode on the entire internet.
- The old crypt(3) password alphabet sorts correctly: sorted encoded strings stand in the same order as sorted plaintext. GEDCOM genealogy files still use that alphabet, and the crate ships it for you.
- BinHex, the old Macintosh wrapper, built its alphabet to exclude visually confusable characters like
7,O,gando. An encoder designed for human eyes, in a world before spellcheck. - The crate's own FAQ is blunt about the padding: exabytes of storage and transfer have no doubt been wasted on pointless
=bytes. The toll booth has been collecting since 1987. - Base64 is not encryption. If it were, you could not read the output of any example in this article. It is a window seat, not a vault.
The Short Version
Pick your engine by the road the data will travel: BASE64_STANDARD for everything you also decode yourself, the _NO_PAD engines when you control both ends and want the bytes back, URL_SAFE_NO_PAD for tokens and URLs, and a custom Alphabet only when a protocol insists. Size your buffers with encoded_len(), stream the big things through EncoderWriter and always close with finish(), wrap lines with line-wrap and pem only when a format demands it, let the SIMD engines do the heavy lifting when you can, and remember that the four-for-three trade is the price of getting through the text-only door. Encode everything, protect only what needs a real lock. And when you need to go the other direction, unpacking a string back into the bytes that started the journey, the sister article covers decoding in Rust, complete with the full scorecard of exact error messages.
Last updated: 2026-08-30
Related article: Base64 Decoding in Rust: A Complete Guide