Base64 Encoding in Python: A Complete Guide
Here is the other side of the coin. You have data in your hands - a file, a password pair, a binary blob, a paragraph of Unicode - and somewhere downstream it has to travel through a channel that accepts nothing but letters. That is the entire job of Base64: rewrite three bytes of data as four characters from a 64-character alphabet, top up the final group with = so everything comes out in fours, and hand the letters over. The home page of this site explains the format in full, alphabet included, so we will keep that to one breath and spend the rest of the time on what Python actually does with it.
The economics deserve one honest sentence before we start, because the number comes up in every conversation about this: the price of that text safety is size. Base64 expands your data by roughly a third, four characters for every three input bytes, so a megabyte of binary becomes a megabyte and a third of letters. For a token or a config value that is a non-issue; for a video file it is the reason you should think about your options.
And the good news: Python's answer to all of it is one import and one function. base64.b64encode has been in the standard library for decades, needs no installation, and runs at C speed underneath. The rest of this guide is the long tail that makes the one-liner useful in the real world: the bytes-only rule that stops half of all TypeError bugs, the URL-safe alphabet, the MIME line-wrapping tools, and the protocols - JWTs, HTTP headers, WebSocket handshakes, email, PEM, data URLs - that Base64 is quietly doing its job inside.
Meet b64encode
The contract fits in four sentences. One: the input is a bytes-like object - bytes, bytearray, memoryview - and a plain string is refused. Two: the output is a bytes object, never a str. Three: the output is always padded to a multiple of four characters, so even a single input byte produces Zg==. Four: the output is one single line, never wrapped, no matter how large the input is. Everything else in this article is commentary on those four sentences:
import base64
encoded = base64.b64encode(b"foobar")
print(encoded)
# b'Zm9vYmFy'
print(len(encoded))
# 8
To get an actual string, for a URL or a header or a JSON field, decode the result as ASCII. The alphabet guarantees nothing else can be in it, which makes this step safe and cheap:
import base64
text = base64.b64encode(b"foobar").decode("ascii")
print(text)
# Zm9vYmFy
There is one more argument on the signature, altchars, and it swaps the + and / of the standard alphabet for a different pair of characters. That is exactly the knob behind the URL-safe variant, so hold that thought - you will meet it in a few sections when we talk about tokens and query strings.
The Type Wall: str Is Not bytes
The first Python-specific wall in this article is the type system, and it is worth learning to feel. b64encode refuses strings with one of the least forgiving error messages in the language:
import base64
try:
base64.b64encode("hello")
except TypeError as caught:
print(caught)
# a bytes-like object is required, not 'str'
The fix is the single most important habit in this whole guide: turn your text into bytes first, and choose the encoding deliberately instead of hoping:
import base64
print(base64.b64encode("été".encode("utf-8")))
# b'w6l0w6k='
print(base64.b64encode("été".encode("utf-16")))
# b'//7pAHQA6QA='
Same characters, two different byte strings, two different Base64 outputs. The encoding choice is a decision, not a detail. UTF-8 is the default for anything that will cross a wire, a database, or an API. UTF-16 shows up when you talk to Windows APIs, and it brings a byte-order mark at the front that you may not want to encode, which you can drop by using utf-16-le or trimming it with lstrip("\ufeff"). Latin-1 still hides in old European files, where one character is exactly one byte and the whole question never arises. The mental model to keep: the encoder never looks at your text, it only ever sees bits. The moment the bytes cross the wall, the charset question is closed - which is also why the decoding side has to ask, later, who owned that charset.
base64url: Swap Two Letters, Drop the Padding
The standard alphabet hides two characters that URLs and file systems hate. The + sign is silently read as a space by any form decoder, and the / sign is a path separator, so a standard-alphabet payload in a query string or a filename is a ticking time bomb. Section 5 of RFC 4648 defines the fix: a variant where + becomes - and / becomes _, where the padding is dropped whenever the data length is known from context, and which the RFC insists on calling base64url and not just "base64". You will meet it in JSON Web Tokens, OAuth tokens, and API cursor parameters, which is to say, in most of the modern web.
Python ships both a dedicated function and the altchars knob from the first section, and they produce identical output:
import base64
data = b"\xfb\xff\xfe"
print(base64.b64encode(data))
# b'+//+'
print(base64.urlsafe_b64encode(data))
# b'-__-'
print(base64.b64encode(data, altchars=b"-_"))
# b'-__-'
In tokens and query strings the padding usually goes as well, because a trailing = would need percent-encoding and some middleboxes mangle it anyway:
import base64
padded = base64.urlsafe_b64encode(b"fooba")
print(padded)
# b'Zm9vYmE='
print(padded.rstrip(b"="))
# b'Zm9vYmE'
Strip, send, and the receiver adds the pads back with the modulo trick, "=" * (-len(s) % 4), which produces exactly as many pads as the length requires. The rule of thumb: if the data will sit in a URL, a filename, or a JWT, use the urlsafe variant and drop the pads; if it will sit in an email body or a text file, the standard alphabet with its padding is the norm.
When Your Reader Wants Lines: MIME and the 76-Character Rule
b64encode's one endless line is perfect for JSON fields, headers, and URLs, but email has opinions. RFC 2045, the MIME standard, requires Base64 output to be broken into lines of at most 76 characters, and Python's legacy tools were built to produce exactly that. encodebytes, added in Python 3.1, does the wrapping for a bytes object:
import base64
wrapped = base64.encodebytes(b"x" * 100)
for line in wrapped.splitlines():
print(len(line), line[:12])
# 76 eHh4eHh4eHh4
# 60 eHh4eHh4eHh4
The mechanics are a little cute. The module encodes in 57-byte chunks, the constant MAXBINSIZE, because 57 bytes become exactly 76 characters, and in modern CPython each wrapped line ends with a plain line feed. RFC 2045 asked for CRLF, but Python's LF output is accepted by every decoder in the ecosystem, including Python's own. The legacy file-to-file function encode does the same wrapping straight from one file handle to another, which makes it a tidy tool for big files you do not want to hold in memory twice.
Which tool when, in short: b64encode for anything that goes into a JSON field, a URL, a header, or a database column; encodebytes for email bodies and PEM-style armor; the legacy encode when you are streaming a large file and want the wrapping for free. Picking the wrong one is a classic bug, because a single stray line break inside a JSON field is enough to make a strict decoder on the far side throw an exception.
The Extended Family
The base64 module is really the base-N module, and it carries the whole RFC 4648 family plus a couple of relatives from other corners of the computing world. Most of them are one-line drop-ins for the same bytes-in-bytes-out contract:
| Functions | Alphabet | When You Meet It |
|---|---|---|
b16encode / b16decode |
0-9a-f |
"Base16" is just hexadecimal; the fastest round trip in the module, great for hashes and UUIDs |
b32encode / b32decode |
A-Z2-7 |
license keys and activation codes; no 0, O, 1 or I, so it survives being read aloud |
b32hexencode / b32hexdecode |
0-9A-V |
Base32 with a hex alphabet, added in Python 3.10; keeps encoded data lexicographically sortable |
a85encode / a85decode |
85 printable characters | ASCII85 from PostScript and PDF, the descendant of the Unix btoa utility; in the module since Python 3.4 |
b85encode / b85decode |
85 printable characters | the RFC 1924 Base85 alphabet, the one you meet in interoperability specs; also since Python 3.4 |
z85encode / z85decode |
85 printable characters | ZeroMQ's Z85, added in Python 3.13; frames data in groups of four bytes |
None of them change the rules you have already learned: bytes in, bytes out, an alphabet to pick, and a matching decode function waiting on the other end. In practice you will reach for b16 whenever a human should be able to read the value, for b32 when the value will be typed or spoken by hand, and for the 85-character cousins only when a specification tells you to. For everything else, the Base64 pair from the start of this article is the right tool, and it is the one every other part of it builds on.
Images in the Page: Data URLs
The most visible Base64 on the web is the data: URI: media embedded directly in HTML or CSS so the browser does not fire a second request. The format is data:, the media type, the word base64, a comma, and the encoded bytes. Building one from a file on disk is a three-liner:
import base64
with open("logo.png", "rb") as handle:
encoded = base64.b64encode(handle.read()).decode("ascii")
uri = "data:image/png;base64," + encoded
print(uri[:40])
# data:image/png;base64,iVBORw0KGgoAAAAN...
Two cautions, both cheap to observe. First, the browser will happily render a data URI, and it will happily cache megabytes of them: for anything beyond a few kilobytes, a normal image request with a proper cache header wins on every metric that matters. Second, the media type after the colon is a promise. If the bytes are a JPEG, the URI says image/jpeg, because some tooling validates the pair and some renderers simply refuse to guess. The .decode("ascii") step is not decoration either; without it you are concatenating a bytes object to a string and collecting a TypeError, the type wall making its rounds.
Tokens You Can Hand Out: JWTs
A JSON Web Token is three base64url pieces joined by dots: a header, a payload, and a signature. If you are issuing real tokens, do not hand-roll the pieces. Install PyJWT (pip install pyjwt) and let it build the base64url parts, the padding, and the signature in one call:
import jwt
token = jwt.encode(
{"sub": "1234567890", "name": "John Doe"},
"super-secret-key",
algorithm="HS256"
)
print(token)
# eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIi...
print(type(token))
# <class 'str'>
Under the hood PyJWT is doing exactly what this article describes: serialize to JSON, run it through the urlsafe encoder, and strip the pads, per the JWS definition in RFC 7515. If you ever need to assemble a piece by hand, for a test fixture or a debugging session, the recipe is the same arithmetic everywhere:
import base64
import json
payload = json.dumps({"sub": "1234567890"}).encode("ascii")
part = base64.urlsafe_b64encode(payload).rstrip(b"=")
print(part)
# eyJzdWIiOiAiMTIzNDU2Nzg5MCJ9
One note on direction of trust: building a token is the easy half. The receiver must verify the signature before trusting a single claim, and PyJWT 2.x will not decode a token without an explicit algorithms list, which is a feature, because the "any algorithm" mistake is one of the most expensive lines of authentication code ever written.
HTTP: Basic Auth and the WebSocket Handshake
Two HTTP moments live or die on Base64. The first is the oldest authentication scheme in the protocol: Basic auth (RFC 7617), where the client sends user:pass, base64-encoded, behind the word Basic:
import base64
credentials = base64.b64encode(b"jane:pa:ss").decode("ascii")
header = "Basic " + credentials
print(header)
# Basic amFuZTpwYTpzcw==
If requests is already in your stack, it builds this header for you with auth=("jane", "pa:ss"), which is worth using because it keeps the encoding detail out of your code. And be honest about what is happening while you are at it: RFC 7617 is blunt that the scheme "does not provide any protection against eavesdropping". The credentials are recoverable in one line of code by anyone who sees the traffic, so this is a convenience for TLS-protected connections, not a security boundary.
The second moment is the WebSocket handshake (RFC 6455), where the server proves it read the client's random key by answering with Base64 of an SHA-1 hash of the key glued to a magic GUID:
import base64
import hashlib
key = "dGhlIHNhbXBsZSBub25jZQ=="
magic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
accept = base64.b64encode(
hashlib.sha1((key + magic).encode("ascii")).digest()
).decode("ascii")
print(accept)
# s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
The output is the exact value from the worked example in the RFC itself, which is a lovely way to check a from-scratch implementation. In production the websockets library does this step for you on both ends; you only hand-craft it when you are writing the tiny test server that proves your understanding.
Email: The Original Customer
Base64 was standardized in 1993 for one job, and that job was email: making binary survive the text-only world of SMTP, per RFC 2045's Content-Transfer-Encoding: base64. Python's email package builds the message, picks the encoding, and wraps the body at the standard line length without you writing a line of Base64 yourself:
import email.mime.multipart
import email.mime.application
msg = email.mime.multipart.MIMEMultipart()
msg["Subject"] = "binary payload"
part = email.mime.application.MIMEApplication(
b"\x00\x01\x02", _subtype="octet-stream"
)
msg.attach(part)
text = msg.as_string()
print(text)
# ...
# Content-Transfer-Encoding: base64
#
# AAEC
# --...
The MIMEApplication part is the interesting one: it wraps the bytes in the correct 76-character lines and stamps the transfer-encoding header, which is exactly the encodebytes behavior from earlier applied by the framework. If you are assembling a bare snippet rather than a full message, email.encoders.encode_base64(obj) does a single encode-and-wrap directly on a message object. And when the message arrives on the other end, the decoding side of the story, from get_payload(decode=True) to header decoding, is covered in the related decoding article.
PEM Armor for Keys and Certificates
PEM files - the certificates, private keys, and CRLs that open with -----BEGIN ...----- - are nothing but armor around Base64: a label line, wrapped Base64, a closing label. The armor is easy to see through, because the body is just the wrapped output you already met:
import base64
der = b"\x30\x03\x02\x01\x05" # a tiny DER blob for illustration
body = base64.encodebytes(der).decode("ascii")
armor = ("-----BEGIN CERTIFICATE-----\n"
+ body
+ "-----END CERTIFICATE-----")
print(armor)
# -----BEGIN CERTIFICATE-----
# MAMCAQU=
# -----END CERTIFICATE-----
Strip the two label lines, join the rest, and b64decode hands you the DER bytes back. In production you will almost never do this by hand: the cryptography package (pip install cryptography) generates the armor with public_bytes and parses it with load_pem_x509_certificate and friends, doing the Base64 step for you under the hood. The manual path earns its keep in the specific moment when the raw DER bytes are already in your hands - a database column, a config file, a buffer from a protocol - and the specification in front of you says "PEM, please".
Shipping Files: Uploads, Downloads and the .b64 Habit
The oldest use case on the internet is a binary file that has to cross a channel that only carries text: an FTP that mangles line endings, a form that refuses uploads, a chat window that eats binary. The recipe is read, encode, ship, and let the far end decode, and the interesting half of it is the middle two steps:
import base64
with open("photo.png", "rb") as src:
data = src.read()
wrapped = base64.encodebytes(data)
with open("photo.b64", "wb") as dst:
dst.write(wrapped)
print(len(wrapped), "bytes on disk for", len(data), "in the photo")
# about a third larger than the original
Two notes. The .b64 extension is a community convention, not a standard, so the receiving side has to know the convention too - which is why JSON APIs usually wrap the payload in a named field like "image_base64" and say so in their documentation. And the wrapped 76-character lines from encodebytes are the format of choice for the file on disk, because they copy-paste cleanly through every text tool humans own, from mail clients to PDF readers. The reverse direction, reading such a file back into bytes, is one call in the related decoding article; here you are only the sender, and the sender's job is to be consistent.
The Storage Question: Config Files, Env Vars and Databases
Developers love putting Base64 in places that only accept text: a .env file, an .ini setting, a TEXT column. The encoding step is trivial, and the most common shape is JSON-in-Base64:
import base64
import json
config = {"api_user": "svc-bot", "api_pass": "hunter2-not-really"}
packed = base64.b64encode(
json.dumps(config).encode("utf-8")
).decode("ascii")
print(packed)
# eyJhcGlfdXNlciI6ICJzdmMtYm90IiwgImFwaV9wYXNzIjogImh1bnRlcjItbm90LXJlYWxseSJ9
And then the warning, because this is where the most expensive misunderstanding in this whole article lives. Base64 is not obfuscation that holds up, and it is not encryption. Section 12 of RFC 4648 puts it as plainly as an RFC can: base encoding "visually hides otherwise easily recognized information, such as passwords, but does not provide any computational confidentiality". A .env file with Base64 secrets protects you from the person who glances at it, not from the person who reads it, and one command of base64 -d later the "secret" is sitting in their terminal in plain text. If the data is genuinely sensitive, encrypt it first - the cryptography package ships Fernet for exactly this - and only then Base64 the ciphertext if your storage demands text.
A Million Bytes Later: Big Data and Chunking
b64encode is a C-speed function - on a typical laptop it processes a megabyte in roughly a millisecond - but it is not a streaming function. There is no update-and-finish pair anywhere in the standard library, so encoding data larger than you want to hold in memory means doing the boundary arithmetic yourself. Three input bytes make four output characters, so any chunk boundary has to land on a three-byte seam:
import base64
def encode_chunks(chunks):
out = []
leftover = b""
for chunk in chunks:
buffer = leftover + chunk
whole = len(buffer) // 3 * 3
if whole:
out.append(base64.b64encode(buffer[:whole]))
leftover = buffer[whole:]
if leftover:
out.append(base64.b64encode(leftover))
return b"".join(out)
with open("video.mp4", "rb") as handle:
encoded = encode_chunks(iter(lambda: handle.read(65536), b""))
The output is byte-for-byte identical to encoding the whole file in one call, because the three-byte seam is the only place the grouping can break. The padding appears exactly once, on the final chunk, which is what a strict decoder on the far end will expect. The iter(lambda: handle.read(65536), b"") line is the standard idiom for reading a file in fixed-size pieces, and the leftover variable is the entire algorithm. The decoding side keeps a four-character seam instead of a three-byte one, so the two articles split the arithmetic between them rather than repeating it.
Where Encoders Go Wrong
The encoding side has fewer traps than the decoding side, because there is less to go wrong when you are the one producing the letters. Still, these show up every week, and every one of them has a two-minute fix if you recognize it early:
- Feeding a string to the encoder. The
TypeErrorfrom the type wall section. Fix it at the source with.encode("utf-8"), and think about which charset you actually mean before you type it. - Forgetting the output is bytes.
b64encodereturns bytes; thestris what goes into a URL or a JSON field, so the.decode("ascii")step is part of the recipe, not an afterthought. - Hand-rolling the URL-safe swap.
str.replace("+", "-").replace("/", "_")works, but it is two letters of maintenance debt whereurlsafe_b64encodeis one call. Worse, a half-finished swap, pluses fixed and slashes forgotten, produces an alphabet that matches no specification at all. - Leaving the pads in a URL. A trailing
=inside a query string gets percent-encoded by one tool and stripped by another, and the receiver's padding math breaks in the most confusing way. Strip them; the length tells the decoder everything it needs. - Wrapping where it is not wanted.
encodebytesline breaks are correct for email and PEM, and poison for a JSON field or a URL. One stray line break is enough to make a strict decoder on the far end throw an exception about your data, not your formatting. - Double-encoding. The data was already Base64 upstream - a field that arrives pre-encoded from another API, a file that got the
.b64treatment twice - and the second pass produces a string that decodes back to the first encoding. Round-trip once, check the magic bytes, and stop. - Trusting Base64 with secrets. The storage-section warning, repeated because it costs real money: if the threat model includes anyone reading the file, you need a cipher, not an alphabet.
A Changelog You Can Actually Read
The module's age shows in quiet, dated improvements rather than revolutions. The short version, in the order the pieces landed, from the encoder's point of view:
| Version | What Happened |
|---|---|
| Python 2.4 (2004) | Barry Warsaw's full RFC 3548 support ships: the b16, b32 and b64 families, plus the standard_* and urlsafe_* variants used today |
| Python 3.1 (2009) | encodebytes arrives and encodestring is deprecated, a rename that old tutorials are still tripping over |
| Python 3.4 (2014) | every encoder accepts any bytes-like object, and a85encode and b85encode join the module |
| Python 3.6 (2016) | binascii.b2a_base64 learns a newline switch, which is what lets b64encode stay one endless line |
| Python 3.9 (2020) | the legacy encodestring and decodestring names are finally removed |
| Python 3.10 (2021) | b32hexencode and b32hexdecode, the sortable hex-alphabet cousins |
| Python 3.13 (2024) | z85encode and z85decode, ZeroMQ's alphabet, join the family |
| Python 3.14 (2025) | faster imports across the standard library, base64 included, and a C rewrite of b16decode that makes the hex cousin up to six times faster |
The through-line, if you want one: the module was rewritten in 1995 to delegate its work to the C-level binascii module, and that delegation is still true today. The first bytes-era change, a 2007 commit during the Python 3 development that made everything use bytes everywhere, is where the type wall in this article came from, and it is the reason a modern encoder takes bytes and hands back bytes, with everything else a wrapper around that single contract.
Things the Module Does Not Tell You
The serious work is done, so here are the small delights on the encoding side of the ledger:
- The documentation's own example has run the same demonstration for over a decade:
b'data to be encoded'goes in,b'ZGF0YSB0byBiZSBlbmNvZGVk'comes out. You have met this pair before, whether you know it or not. - The C function under
b64encodeadds its trailing newline with a comment that reads "Append a courtesy newline". A whole culture, in one line of source. - The word
passwordencodes tocGFzc3dvcmQ=, which is why Base64 in a log file looks like a secret to a scanner and is one command away from being one to a reader. b64encodenever wraps. Ever. A gigabyte of input produces a single 1.3-gigabyte line, and the function does not blink. If you wanted lines, you had to ask forencodebytes.- The module's docstring still names RFC 3548, the 2003 edition of the spec. RFC 4648 took over in 2006; the docstring simply never noticed.
- Python 2 had no type wall at all:
b64encodehappily accepted astrand returned one. The 2007 bytes-overhaul ended that, and the old Python 2 tutorials are where most "why does my encode crash" threads still point. z85encode, the newest member of the family (Python 3.13), is its pickiest: ZeroMQ frames data in groups of four bytes, so the encoder quietly zero-pads any shorter input into place before encoding.
So the encoder's philosophy in three rules. Decide the bytes first and the encoding second, because the type wall is where most Python Base64 bugs are born. Pick the alphabet for the channel, not for the data: standard with pads for email and files, base64url without pads for URLs and tokens, and never improvise a third variant at the keyboard. And keep the output in the shape its reader expects, one line for JSON and headers, 76-character lines for MIME and PEM, because the decoder on the far end will hold you to it.
When those letters arrive on the other end, the fun really starts: missing pads, silent discards, payloads that are not quite Base64, and a decoder with two moods to navigate. All of that is covered in detail in the related Base64 decoding article at the bottom of this page, and the two guides read well as a pair. Happy encoding.
Last updated: 2026-08-29
Related article: Base64 Decoding in Python: A Complete Guide