Do you have to deal with Base64 format? Then this site is perfect for you! Use our super handy online tool to encode or decode your data.

Base64 Decoding in Python: A Complete Guide

Somewhere in your code, a string of letters just landed that does not look like text at all: a long run of A through Z, a few digits, the occasional + or /, maybe a - or _, and perhaps one or two = signs parked at the end. Behind that string could be the payload of a JWT your gateway rejected, an image hidden inside a page of HTML, a file someone mailed you as a .b64 attachment, or a certificate block in a ticket that has traveled through three help desks. Your job is to hand back the original bytes, exactly as they were. And Python is in a great mood for this job, because the whole toolbox has shipped in the standard library for decades: one line of import base64 and you are ready on every platform, with nothing to install and nothing to configure.

A quick refresher while you settle in, because everyone needs it once a year: Base64 rewrites every three bytes of data as four characters drawn from a 64-character alphabet, and when the final group of three bytes is incomplete, = padding fills the group out so the output always comes in fours. That is the whole trick. It is not compression and it is not secrecy, only a way to let binary survive channels that accept nothing but text. The home page of this site walks through the format in full depth, including the alphabet and the padding math, so we will spend our energy where the pain actually lives: on the Python side of decoding, and on keeping the result honest.

Three facts shape everything that follows, and they are worth memorizing before you read another line. First, the decoder has two moods: a polite, forgiving default that silently discards anything it does not recognize, and a strict mode that refuses such input outright. Second, the result of a decode is always a bytes object, never a string, and the moment you want real text out of it is a decision you have to make deliberately. Third, there are two alphabets that look almost identical, the standard one and the URL-safe one, and mixing them up is a favorite way to lose data without any error at all. This guide walks you past all three, so that the next time a wall of gibberish lands in your terminal, you can be smiling instead of squinting.

The Full Decode Menu

Open the base64 module and you will find two generations of interface sitting side by side. The modern one, centered on b64decode, converts bytes-like objects (and plain ASCII strings) back into bytes, and it speaks both Base64 dialects defined in RFC 4648. The legacy one is older and file oriented: it works on file objects, it knows only the standard alphabet, and it was built around the 76-character wrapped lines that RFC 2045, the 1996 MIME mail standard, demanded from encoded output. You will meet the legacy names in plenty of code that has been around a while, so here is the complete decoding side of the menu:

Function What It Does Notes
base64.b64decode(s, altchars=None, validate=False) the workhorse: a Base64 blob back to raw bytes accepts bytes or an ASCII string, always returns bytes
base64.standard_b64decode(s) the same job, locked to the standard alphabet handy when you know the dialect for certain
base64.urlsafe_b64decode(s) reads the URL-safe alphabet with - and _ the one that reads JWTs
base64.decodebytes(s) decodes one or more wrapped lines of Base64 added in Python 3.1, the MIME-friendly path, lenient
base64.decode(input, output) streams a Base64 file into a raw file legacy, reads line by line, lenient
base64.b32decode(s, casefold=False) decodes the smaller Base32 cousin casefold accepts lowercase input
base64.b16decode(s, casefold=False) decodes Base16, which is plain hexadecimal got a C speed-up in Python 3.14
binascii.a2b_base64(s, strict_mode=False) the C-level function doing the real work a direct handle on strictness, with strict_mode since Python 3.11

Everything below builds on the first row. One fact is worth knowing before you go deeper: in the official documentation the module lives under "Internet Data Handling", right next to binascii, and that placement is not an accident. b64decode is a thin wrapper that translates the alphabet (when you pass altchars) and then lets the C-level binascii.a2b_base64 do the heavy lifting. That is why the function is fast, and why its error messages have the crisp, unsentimental flavor of C.

The Workhorse: b64decode

Here is the entire contract, short enough to keep in your head. The function takes a bytes-like object or an ASCII string, an optional two-character alphabet swap, and a validation flag. It hands back a bytes object. On failure it raises binascii.Error, which is a subclass of ValueError in case you ever need to catch a family of exceptions at once:

import base64
data = base64.b64decode("Zm9vYmFy")
print(data)
# b'foobar'
print(type(data))
# <class 'bytes'>

That last line is the single most important line in this article. The result is bytes, not a string, and Python holds your hand exactly as far as it should: printing the object shows you the b'...' representation, and trying to glue it to a string raises a TypeError. The moment you want actual text, the decision is yours to make, and the charset section below covers when that decision is easy and when it is a trap.

The optional altchars argument swaps the + and / of the standard alphabet for a different pair of characters. That is precisely the knob that produces the URL-safe dialect, and it is how urlsafe_b64decode is built on top of b64decode. You will rarely reach for altchars yourself, but it is good to know the machinery is there. For everything else, the function simply does the job, fast, in C.

Lenient by Default, Strict on Request

By default, b64decode is a polite forgetter. Any character that is not in the 64-character alphabet (and not in your altchars) is quietly thrown away before the decoding starts, and whatever survives gets decoded. No warning, no notice, no return value to check, just a result. That tolerance has a noble ancestor: section 6.8 of RFC 2045 tells decoders that "all line breaks or other characters not found in Table 1 must be ignored", because SMTP historically wrapped long lines and sprinkled stray characters along the way. A payload that crossed a mail client, a chat app, or a PDF copy will often decode without any preparation at all, and that is a genuine superpower.

The same kindness is also why the default decoder is useless as a validator. Section 12 of RFC 4648 spells out the risk: ignoring non-alphabet characters instead of rejecting the whole encoding opens a covert channel that can be used to leak information, and it can break string equality checks, because two different inputs can decode to the same bytes. For anything you did not encode yourself, pass validate=True and treat the exception as the answer. Here is the damage report, every row reproducible on any modern Python:

What Goes In Lenient (default) validate=True
Zm9vYmFy (a clean payload) b'foobar' b'foobar'
Zm9v\r\nYmFy (line break in the middle) b'foobar' binascii.Error
Zm9v YmFy (extra spaces) b'foobar' binascii.Error
Zm9v!YmFy (a stray exclamation mark) b'foobar' binascii.Error
junkZm9vYmFy (a word in front of the payload) b'\x8e\xe9\xe4foobar' binascii.Error
Zm9v=YmFy (a pad in the middle) b'foobar' binascii.Error
=Zm9v (padding up front) b'fo' binascii.Error
==== (four pads, no data) b'' binascii.Error
(empty input) b'' b''

Watch the lenient column do its quiet work. The row that surprises people first is the one with a word up front: all four letters of junk happen to sit in the Base64 alphabet, so the "garbage" decodes as three real bytes and gets glued to your payload with a straight face. In strict mode the same input is refused flat out, and the refusals have exactly one shape: a binascii.Error carrying one of a handful of memorable messages:

  • Incorrect padding - the length is not a multiple of four after discarding, or a final group is too short. A string like Zm9vYmE with no pads at all lands here.
  • Invalid base64-encoded string: number of data characters (N) cannot be 1 more than a multiple of 4 - the input is exactly one character short of the next group. This is the classic fingerprint of a truncated or copy-pasted payload.
  • Only base64 data is allowed - a non-alphabet character survived into strict mode, and a single newline counts as one.
  • Excess padding not allowed - pads in the middle of the string, or more pads than the final group allows.
  • Leading padding not allowed - the string starts with =.
  • And one from a different family: ValueError: string argument should contain only ASCII characters, which you get when you pass a string with non-ASCII letters in it. Strings are accepted, but only ASCII ones.

Behind the scenes, validate=True is not a separate code path at all. The module forwards the flag to binascii.a2b_base64 as its strict_mode parameter, the strict check that was added to binascii in Python 3.11. That gives you a direct handle when you want strictness without going through the base64 layer:

import binascii
line = b"Zm9vYmFy"
print(binascii.a2b_base64(line, strict_mode=True))
# b'foobar'

One quirk to pin down before you trust strict mode blindly: it rejects even a single trailing newline, so a MIME-wrapped block is a job for the lenient path or for decodebytes, not for validate=True. Keep the strict path for data you expect to be perfectly clean, like a freshly minted token straight out of your own code.

base64url, the Alphabet That Fits in URLs

The standard alphabet has two characters that URLs hate. The + sign is read as a space by any form decoder, and the / sign is reserved for path separators. Section 5 of RFC 4648 defines the cousin dialect, where + becomes - and / becomes _, and the padding is dropped whenever the data length is known from context. The RFC even gives the variant a proper name, base64url, and insists that it should not be called just "base64". You will meet it most often inside JSON Web Tokens, where every part of the token is base64url without padding, and it also shows up in OAuth tokens and API cursor parameters.

Python ships a dedicated function for it, urlsafe_b64decode. It translates the dashes and underscores back into plusses and slashes and then decodes, but it will not re-pad for you. Unpadded input is the normal case for JWTs, so the arithmetic line comes first, and it is the same one that libraries like PyJWT use under the hood:

import base64
segment = "Zm9vYmE"
padded = segment + "=" * (-len(segment) % 4)
print(base64.urlsafe_b64decode(padded))
# b'fooba'

The expression "=" * (-len(segment) % 4) looks like a trick, but it is the whole job: it produces zero, one, or two pads and never three, so an already-padded string passes through untouched. The negative modulo is what makes it work for strings of every length, and it is the one line of Base64 arithmetic every Python developer ends up typing at least once.

Now the dangerous mixup, because the two alphabets look close enough to confuse. Run a base64url string through the standard decoder and the dashes and underscores are simply not in the standard alphabet, so the lenient decoder swallows them and decodes whatever is left. For some payloads that is a mangled byte stream; for others it is nothing at all:

import base64
tricky = base64.urlsafe_b64encode(b"\xfb\xff\xfe")
print(tricky)
# b'-__-'
print(base64.standard_b64decode(tricky))
# b'' - every character was quietly discarded
print(base64.urlsafe_b64decode(tricky))
# b'\xfb\xff\xfe'

The reverse direction is forgiving, which is what makes the mixup go unnoticed: urlsafe_b64decode translates its alphabet first and then decodes leniently, so it will happily accept a standard-alphabet string with + and / in it. The lesson is not to improvise. It is to pick one function per dialect and stick to it, the way you would with a foreign currency: spend the yen where the yen is valid, not at the wrong exchange office.

The Output Is Bytes: The Charset Conversation

Here is the sentence that settles half the charset questions people bring to Base64: b64decode decodes bytes, it does not decode text. There is no charset argument, there is no conversion, and nothing about the input tells Python what the bytes are supposed to mean. The meaning is something you must supply from context, and that context is almost always one of three things: a header that says so, an API contract that says so, or a magic number hiding in the bytes themselves.

import base64
raw = base64.b64decode("w6l0w6k=")
print(raw)
# b'\xc3\xa9t\xc3\xa9'
print(raw.decode("utf-8"))
# été

The same idea with the wrong label is a loud failure, which is a mercy. Bytes that are not valid UTF-8 refuse to become a string, and the exception tells you exactly which byte offended:

import base64
raw = base64.b64decode("/w==")
try:
  raw.decode("utf-8")
except UnicodeDecodeError as caught:
  print(caught)
# 'utf-8' codec can't decode byte 0xff in position 0: invalid start byte

Three rules of thumb keep this section from becoming a horror show. One: when the decoded data is JSON, you do not need to decode manually at all, because json.loads has accepted bytes directly since Python 3.6 and detects UTF-8, UTF-16, and UTF-32 on its own. Two: binary is not text, so a "detected" charset for a PNG is a lucky guess rather than a fact; check the bytes instead of the label. Three: if the sender told you the charset, believe the sender, because a content-type header or an API document outranks any detector, every single time.

Where Decoded Base64 Shows Up in Python Code

After a while you start recognizing the shapes. Here is the field guide to the places decoded Base64 turns up in a Python application, and the one-line recipe for each. The sections that follow give the full treatment to the most common ones:

You Find It In What It Is How To Read It
A JWT header, payload and signature parts (RFC 7519) split on the dot, urlsafe_b64decode with the padding fix
An Authorization header HTTP Basic credentials, user:pass (RFC 7617) strip the Basic prefix, decode, split at the first colon
A data: URI inline media in HTML or CSS (RFC 2397) cut at the first comma, decode the rest
A mail attachment a Content-Transfer-Encoding: base64 body (RFC 2045) get_payload(decode=True) on the message part
A mail header value an =?charset?b?...?= encoded word (RFC 2047) let the email package decode it for you
A PEM file an armored key or certificate (RFC 7468) drop the armor lines, decode the body to DER
A JSON API field binary smuggled around as a string decode, then treat the result as bytes, not text
A TEXT column or env var binary or JSON stored in a text-only place decode, then parse or write, with the charset you agreed on

Reading a JSON Web Token

A JWT is three base64url pieces joined by dots: a header, a payload, and a signature. The first two are plain JSON, so peeking into them is one line each, using the padding fix from the section above:

import base64
import json
def read_part(segment):
  padded = segment + "=" * (-len(segment) % 4)
  return base64.urlsafe_b64decode(padded)
token = ("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."
         "eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0."
         "8Rmup2hf8jZvoBgoCRqRWlBFNtvUYmA0eR7YKellPMs")
head, body, _signature = token.split(".")
print(json.loads(read_part(head)))
# {'alg': 'HS256', 'typ': 'JWT'}
print(json.loads(read_part(body)))
# {'sub': '1234567890', 'name': 'John Doe'}

A scope note, because it matters: inspecting a token this way is a debugging tool, not an authentication mechanism. The payload being readable does not mean it is genuine; an attacker can forge the first two dots without ever knowing your secret. For real verification, hand the token to PyJWT (pip install pyjwt), which checks the signature and refuses to decode without an explicit algorithm list:

import jwt
decoded = jwt.decode(token, "super-secret-key", algorithms=["HS256"])
print(decoded)
# {'sub': '1234567890', 'name': 'John Doe'}

With a wrong key you get an exception instead of a dictionary, which is exactly the behavior you want in production code. And if the token arrived with an expired timestamp, PyJWT raises for that too, so you never have to remember the claim names yourself.

Opening a Data URI

Data URIs embed media directly inside HTML or CSS so the browser does not fire a second request: data:, the media type, the word base64, a comma, and the encoded bytes. The split is at the first comma, full stop, and everything after it is a plain standard-alphabet payload:

import base64
uri = ("data:image/png;base64,"
       "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ"
       "AAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==")
mime, payload = uri.split(",", 1)
data = base64.b64decode(payload)
print(mime)
# data:image/png;base64
print(data[:8])
# b'\x89PNG\r\n\x1a\n'

The eight-byte PNG signature at the front of the result is a cheap and cheerful check that you decoded the right thing. Two pitfalls deserve a mention. If the URI came from a scraped page or a chat message, strip HTML entities and stray whitespace first, because the lenient decoder will forgive a lot of junk and hand you a corrupted image instead of an error. And if you are decoding untrusted input in bulk, pass validate=True: a data URI that fails strict validation is a data URI that was never well-formed, and you do not want to write it to disk on a hunch.

Cracking the Authorization Header

Basic authentication (RFC 7617) is the oldest scheme in HTTP, and it still anchors a surprising number of API integrations, webhooks, and CI pipelines. The client sends its credentials as user:pass, base64-encoded, behind the word Basic:

import base64
header = "Basic amFuZTpwYTpzcw=="
decoded = base64.b64decode(header[len("Basic "):]).decode("utf-8")
user, _, password = decoded.partition(":")
print(user, password)
# jane pa:ss

Notice the partition, because it is the detail that saves you later: the password may contain colons, the user id may not, and only the first colon is the separator. One honest note, because the RFC itself is blunt about it: base64 is not encryption. RFC 4648 says that base encoding "visually hides otherwise easily recognized information, such as passwords, but does not provide any computational confidentiality". A Basic header can be decoded by anyone who sees the traffic, so treat it as a convenience for TLS-protected connections, not as a security boundary. When you are the one sending the header, requests builds it for you with auth=("jane", "pa:ss"), which is worth using whenever the library is already in your stack.

Email, the Original Customer

Base64 was standardized in 1993 for exactly one job: making binary survive email. RFC 2045, the MIME standard, defined the Content-Transfer-Encoding: base64 body encoding, and it is still the default way attachments travel across the internet. Python's email package does the whole job for you: it parses the headers, it decodes the =?utf-8?b?...?= encoded words that RFC 2047 hides in header fields, and it base64-decodes bodies when you ask for it:

import email
from email import policy
raw = (b"Subject: =?utf-8?b?w6l0w6k=?=\r\n"
       b"From: sender@example.com\r\n"
       b"To: reader@example.com\r\n"
       b"Content-Transfer-Encoding: base64\r\n"
       b"\r\n"
       b"w6l0w6kgbWFpbA==\r\n")
msg = email.message_from_bytes(raw, policy=policy.default)
print(msg["Subject"])
# été
print(msg.get_payload(decode=True))
# b'\xc3\xa9t\xc3\xa9 mail'

The get_payload(decode=True) call reads the Content-Transfer-Encoding header and base64-decodes the body for you, unwrapping the 76-character lines along the way. The policy=policy.default argument selects the modern interface from Python 3.6 onward (PEP 553), which gives you decoded header values out of the box; the legacy parser still works, but you end up decoding encoded words by hand. You only drop down to decodebytes when you are parsing a bare snippet that is not a full message, like a block someone pasted into a ticket. For multipart messages, iterate with iter_attachments() and give each part the same one-line treatment.

PEM Armor and the cryptography Package

A PEM file is a header line, some wrapped Base64, and a footer line, and nothing more. The armor is decorative; the Base64 is the whole story, because it decodes to the raw DER structure underneath. The cryptography package (pip install cryptography) can load the result directly, which is why it is the standard tool for anything involving certificates and keys:

import base64
from cryptography import x509
pem = b"""-----BEGIN CERTIFICATE-----
MIIBGzCBwaADAgECAgEBMAoGCCqGSM49BAMCMBcxFTATBgNVBAMMDGV4YW1wbGUu
dGVzdDAeFw0yNjA4MjkxNzIxMzZaFw0yNjA4MzAxNzIxMzZaMBcxFTATBgNVBAMM
DGV4YW1wbGUudGVzdDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABPvNHjdF4b1n
SkBDT6UWtG2k8ICe45eL3kSkVfuhriev1uO9PBLMP50HWnrLbCXtl3lhWaVibctl
QbWRG4xqGLcwCgYIKoZIzj0EAwIDSQAwRgIhAKdFm5GLecg2fF7qUhSmKGtgNFaL
qVyKtDXK07N6GZd/AiEAtRXemnYqDMz77o9+VpM/NsNEwDi0yaVB+tKGLbdKJb0=
-----END CERTIFICATE-----
"""
body = b"".join(pem.splitlines()[1:-1])
der = base64.b64decode(body)
cert = x509.load_der_x509_certificate(der)
print(cert.subject.rfc4514_string())
# CN=example.test

In most production code you never do the armor-and-decode by hand: load_pem_x509_certificate accepts the armored bytes and handles the Base64 step for you under the hood. The manual path earns its keep when the DER bytes are already in your hands (a database column, a config file, a byte buffer from a protocol), or when the block arrived wrapped in a string and you want to see what is inside before you trust it. Keys work the same way, with load_der_private_key waiting on the other side of the same decode.

Files, Magic Numbers and the .b64 Habit

Decoding is only half the job; the bytes usually want a file. The pattern is read, decode, check, write, and the check matters because a broken payload would otherwise produce a silently wrong file that you will discover weeks later:

import base64
import binascii
with open("payload.b64", "rb") as handle:
  encoded = handle.read()
try:
  data = base64.b64decode(encoded, validate=True)
except binascii.Error:
  data = base64.b64decode(encoded)
with open("payload.bin", "wb") as out:
  out.write(data)

For quick one-shot conversions, the legacy file-to-file function does the whole trip in a single call, wrapped lines and all:

import base64
with open("photo.b64", "rb") as src, open("photo.png", "wb") as dst:
  base64.decode(src, dst)

What did you just decode, anyway? The first bytes of almost every common format are a fixed signature, and because Base64 is deterministic, the encoded signature is fixed too. Seeing one of these prefixes is like recognizing a license plate at a distance:

The Base64 Starts With It Probably Is
iVBORw0KGgo a PNG image
/9j/ a JPEG image
R0lGODlh a GIF image
JVBERi0 a PDF document
UEsDBA== a ZIP archive
UklGRg== a RIFF container (WAV, WEBP, AVI)
LS0tLS1CRUdJTg== an ASCII-armored block ("-----BEGIN ...")

And do the size math while the file writes, because it is the number that surprises people when the disk fills up: encoding inflates data by roughly a third, so a 300 KB file travels as about 400 KB of Base64 text, and the file you decode back is the smaller, original size. Your disk, and your memory if you read the whole file at once, should budget for the difference.

Databases, Config Files and Environment Variables

Base64 is a favorite for smuggling binary (or JSON) through storage that only accepts text: a TEXT column, a value in an .ini file, an environment variable in a deploy pipeline. The decoding recipe is the same as for files, minus the disk:

import base64
import json
stored = "eyJyb2xlIjogImFkbWluIiwicHJvamVjdCI6InN1cGVyc2l0ZSJ9"
payload = json.loads(base64.b64decode(stored))
print(payload)
# {'role': 'admin', 'project': 'supersite'}

Two notes for this corner of the house. When the stored value is JSON, skip the intermediate .decode("utf-8") step and let json.loads take the bytes directly, since it has done so since Python 3.6. And one honest warning, because this is where the most expensive misunderstanding in the whole article lives: Base64 in an env var or a config file is a shield against the human who glances at the file, not against the one who reads it. If the value 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.

When the Payload Arrives in Pieces

The standard library has no incremental Base64 decoder: there is no update-and-finish pair, so streaming data needs a little bookkeeping of your own. The arithmetic is simple and strict at the same time. Four encoded characters make three bytes, so you can only decode complete four-character groups, and you must carry the remainder over to the next chunk:

import base64
def chunked_decode(chunks):
  out = []
  leftover = b""
  for chunk in chunks:
    buffer = leftover + chunk
    whole = len(buffer) // 4 * 4
    if whole:
      out.append(base64.b64decode(buffer[:whole]))
    leftover = buffer[whole:]
  if leftover:
    out.append(base64.b64decode(leftover + b"=" * (-len(leftover) % 4)))
  return b"".join(out)

Feed it a socket buffer, a file read in 64 KB pieces, or a generator of lines, and the output is identical to decoding the whole thing in one go. If your input is guaranteed clean and unwrapped, keep the strictness by decoding each complete group with validate=True, and remember that the final remainder may need the padding fix, which is why the helper adds it before the last decode. This is the same seam logic the encoders use on the other side, only with four characters instead of three bytes.

From the Command Line

The base64 module doubles as a tiny command-line tool, which is handy when the payload is sitting in your terminal instead of your code. Encoding is the default; -d (or its twin, -u) decodes:

echo -n "hello world" | python3 -m base64
aGVsbG8gd29ybGQ=
echo -n "aGVsbG8gd29ybGQ=" | python3 -m base64 -d
hello world

It reads from stdin when you give it no file, or from the file you name, and under the hood it is the legacy file-to-file interface, so the output comes wrapped at 76 characters with a trailing newline on each line. For pasting a payload into a session with the strictness turned up, the one-liner version of the decoder is a nice habit:

import base64
import sys
print(base64.b64decode(sys.stdin.read(), validate=True))

Nine Ways to Get Burned

Every Base64 decoding bug in Python is one of these. Keep the list somewhere you will find it in a panic, because it has caught more afternoons than any other single document you will read this year. The first three come with code, because they are easier to remember once you have seen the wreckage:

The missing padding. The most common crash of all, usually because a JWT part or an API value arrived without its pads:

import base64
import binascii
segment = "Zm9vYmE"
try:
  base64.urlsafe_b64decode(segment)
except binascii.Error as caught:
  print(caught)
# Incorrect padding
padded = segment + "=" * (-len(segment) % 4)
print(base64.urlsafe_b64decode(padded))
# b'fooba'

The truncated string. When the error says the number of data characters "cannot be 1 more than a multiple of 4", the payload was cut off in transit, or a copy-paste dropped a character at the end. No amount of padding fixes a string whose length is one modulo four; the data simply is not there, and the honest answer is to ask for the payload again.

The silent garbage. Lenient mode decodes whatever survives, and ordinary English words are full of Base64-alphabet letters, so a stray word in front of the payload becomes real bytes glued to your data:

import base64
print(base64.b64decode("junkZm9vYmFy"))
# b'\x8e\xe9\xe4foobar' - three bytes of pure fiction, then the truth

The other six need no code at all:

  • You decoded a base64url string with the standard decoder. The dashes and underscores are not in the standard alphabet, so they vanished silently and the payload came out mangled, or empty. Use urlsafe_b64decode with the padding fix.
  • You forgot the result is bytes. Gluing it to a string raises a TypeError, and pushing it into a JSON response serializes the b'...' representation. Call .decode(encoding) at the boundary, deliberately, with the encoding you actually mean.
  • You passed a non-ASCII string. The decoder accepts strings, but only ASCII ones; anything else is a ValueError. If your payload came out of a text file that was read with the wrong encoding, fix the read, not the decode.
  • You decoded twice. The data was already decoded upstream, or it was Base64 of Base64, and the second pass turned your password into six bytes that no human will ever read again.
  • You used strict mode on wrapped data. A single newline is enough to make validate=True throw, so MIME blocks and PEM bodies belong to the lenient tools, not to the strict one.
  • You trusted a pad in the middle. In lenient mode an = anywhere in the string is silently discarded, so a corrupted payload with a misplaced pad can decode to the "right" answer. Only strict mode notices, and it notices by refusing.

If your job is to be the gatekeeper, here is a small helper that puts the two moods to work together: strict first, padding fix second, and a loud failure when neither helps:

import base64
import binascii
def safe_decode(text):
  candidate = text.strip()
  try:
    return base64.b64decode(candidate, validate=True)
  except binascii.Error:
    padded = candidate + "=" * (-len(candidate) % 4)
    return base64.b64decode(padded, validate=True)
print(safe_decode("Zm9vYmE"))
# b'fooba'
print(safe_decode("Zm9vYmFy"))
# b'foobar'

Note that the helper still trusts the alphabet it is told to trust. If your input might be base64url, feed it to urlsafe_b64decode instead. Validation is a contract, and the contract says which dialect the data is in.

Three Decades of a Quiet Module

The module has been in the standard library for a quarter of a century, and most of the time it sat still. When it did move, the moves were small but real, and they explain a few "works on my machine" stories that float around old forums:

  • 1995 - Jack Jansen rewrote base64.py to delegate the real work to the C-level binascii module. The comment is still in the file, and the delegation is still true today.
  • 2003, shipped in Python 2.4 - Barry Warsaw added full RFC 3548 support: the b16, b32 and b64 families, plus the standard_* and urlsafe_* variants you use today.
  • Python 3.1 - encodestring and decodestring were deprecated in favor of encodebytes and decodebytes, the names that stuck.
  • Python 3.3 - the decode functions started accepting ASCII strings, ending the era where every decode began with a bytes literal.
  • Python 3.4 - any bytes-like object (memoryviews included) is accepted everywhere, and the Base85 cousins, a85 and b85, joined the module.
  • Python 3.9 - the long-deprecated encodestring and decodestring were finally removed. Old tutorials that call them need a one-word rename.
  • Python 3.10 - b32hexencode and b32hexdecode arrived with the extended hex alphabet, the one that keeps encoded data lexicographically sortable.
  • Python 3.11 - binascii.a2b_base64 gained strict_mode, which is what validate=True rides on under the hood.
  • Python 3.13 - z85encode and z85decode brought ZeroMQ's Z85 dialect into the standard library, and the ancient uu module was removed under PEP 594 with a pointed note to use base64 instead.
  • Python 3.14 - b16decode got a C implementation (up to six times faster), and the module's import time landed on the list of improved modules.

None of this changes what the functions do, which is the quiet luxury of a module this old: code that decoded Base64 in 2005 still decodes it in 2026, on the same line, with the same result.

Delights from the Margins

The serious work is done, so here are the small delights the module hides in its margins:

  • The module's own documentation has run the same demonstration for over a decade: b'data to be encoded' goes in, b'ZGF0YSB0byBiZSBlbmNvZGVk' comes out. If you have read the base64 page of any Python release in the last twenty years, you have met this pair before.
  • The word junk is a perfectly valid Base64 string. All four letters are in the alphabet, which is why a stray word at the start of a payload becomes three bytes of fiction instead of an error, and why the lenient mode earns its nickname.
  • urlsafe_b64decode is accidentally bilingual. It translates its alphabet first and then decodes leniently, so it will also read a standard-alphabet string with + and / in it. One function, two dialects, zero complaints.
  • The error messages are a stable mini-lexicon that has not moved since the C implementation: Incorrect padding, Only base64 data is allowed, Excess padding not allowed, Leading padding not allowed. Learn them and you can triage a broken payload without running a single line of code.
  • The empty string is the only input that gets no reaction at all: b'' in, b'' out, in both moods. Nothing in, nothing out, no alarm.
  • The module's docstring still names RFC 3548, the 2003 edition of the spec. RFC 4648 has been the current standard since 2006, and the module follows it faithfully without bothering to update the sentence.
  • Python 2 had no type wall on the decode side: a plain str in, a plain str out. The 2007 bytes-overhaul of Python 3 development changed that, and the old Python 2 tutorials are where most "why is my decode broken" threads still point.

So here is the whole philosophy in four rules. Pass validate=True for anything you did not encode yourself, and treat the exception as a real answer rather than a suggestion. Know which dialect you are holding, standard, base64url, or MIME-wrapped, because the decoder will not tell you, it will only guess by dropping whatever does not fit. Treat the result as bytes until you have proven it is text, and then ask who owned the charset. And remember that the friendliest feature of this function, the willingness to decode things that are not quite Base64, is the same feature that makes it dangerous, so decide, on every call, how much trust the input has earned.

If at some point you need to go the other way, wrapping fresh bytes back into that friendly ribbon of letters for a token, an attachment, or an inline image, the whole story of b64encode is covered in detail in the related Base64 encoding article at the bottom of this page. The two directions are mirror images, but each has its own set of surprises, and you now know this one by heart. Happy decoding.

Last updated: 2026-08-29

Related article: Base64 Encoding in Python: A Complete Guide