需要使用 Base64 格式吗?那么本网站正好适合您!使用我们的在线工具对数据进行编码或解码,便捷好用。

Python 中的 Base64 解码:完整指南

你的代码里刚落进来一串字母,看起来完全不像文本:一长段 A 到 Z,几个数字,偶尔冒出 +/,也许还有 -_,末尾可能还停着一两个 =。这串字符背后,可能是你的网关拒掉的某个 JWT 的载荷,可能是藏在一整页 HTML 里的一张图片,可能是某人用 .b64 附件发给你的文件,也可能是一个转手过三个客服工单的证书块。你的任务是原封不动地交还最初的字节。而 Python 做这件事正来劲,因为整套工具箱几十年来一直随标准库发货:一行 import base64,任何平台即刻就绪,不用安装任何东西,也不用配置任何东西。

趁你坐定,快速复习一下,因为谁都得一年复习一次:Base64 把数据每三个字节重写成四个字符,取自一个 64 字母的字符表,而当最后那组三个字节凑不齐时,就用 = 填充把这一组补满,让输出永远以四个为一组。这就是全部诀窍。它不是压缩,也不是保密,只是让二进制在只接受文本的通道里活下去的办法。本站首页已经把这个格式讲得足够透彻,包括字母表和填充算术,所以我们把力气花在真正疼的地方:解码的 Python 这一侧,以及让结果保持诚实。

有三个事实决定了后面的一切,值得在继续读之前先背下来。第一,解码器有两种脾气:一种礼貌又宽容的默认脾气,会悄悄丢掉它认不出的任何东西;一种严格模式,直接拒收这类输入。第二,解码的结果永远是一个 bytes 对象,绝不是字符串,而你想从中拿到真正的文本,那个时刻必须由你刻意做出决定。第三,有两种几乎一模一样的字母表,标准版和 URL 安全版,把它们搞混是丢数据却一个错误都不报的经典方式。这份指南会把这三条逐一讲透,这样下次一整墙天书落在你的终端里时,你可以微笑面对,而不是眯眼干瞪。

完整的解码菜单

打开 base64 模块,你会看到两代接口并排坐着。现代那一代以 b64decode 为中心,把 bytes 类对象(以及纯 ASCII 字符串)还原回字节,而且通晓 RFC 4648 定义的两种 Base64 方言。遗留那一代更老,而且面向文件:它操作文件对象,只认识标准字母表,是围绕 1996 年 MIME 邮件标准 RFC 2045 要求编码输出必须采用的 76 字符折行构建的。在那些上了年纪的代码里,你会经常遇到这些遗留名字,所以这是完整的解码菜单:

函数 它做什么 备注
base64.b64decode(s, altchars=None, validate=False) 主力:把一块 Base64 还原回原始字节 接受 bytes 或 ASCII 字符串,永远返回 bytes
base64.standard_b64decode(s) 同一份活儿,锁定标准字母表 当你确定对方方言时很方便
base64.urlsafe_b64decode(s) 读取带 -_ 的 URL 安全字母表 读 JWT 的就是它
base64.decodebytes(s) 解码一行或多行折行后的 Base64 Python 3.1 加入,MIME 友好的路子,宽容
base64.decode(input, output) 把 Base64 文件以流的方式还原进原始文件 遗留接口,逐行读取,宽容
base64.b32decode(s, casefold=False) 解码更小的 Base32 近亲 casefold 接受小写输入
base64.b16decode(s, casefold=False) 解码 Base16,就是纯十六进制 Python 3.14 中最多快六倍
binascii.a2b_base64(s, strict_mode=False) 在 C 层干真正活儿的函数 直接控制严格程度的把手,strict_mode 自 Python 3.11 起可用

下面的一切都建立在第一行之上。在深入之前,有一件事值得知道:在官方文档里,这个模块归在"互联网数据处理"之下,就紧挨着 binascii,而这个位置绝非偶然。b64decode 是一个薄薄的包装层:它先转换字母表(当你传入 altchars 时),然后把重活交给 C 层的 binascii.a2b_base64。这就是这个函数快的原因,也是它的错误信息带着 C 语言那种干脆、不掺感情的味道的原因。

主力选手:b64decode

完整约定如下,短到能装进你的脑子。这个函数接收一个 bytes 类对象或 ASCII 字符串、一个可选的两位字母表替换,以及一个校验标志。它交还一个 bytes 对象。失败时它抛出 binascii.Error,后者是 ValueError 的子类,以便你哪天需要一次抓住一整个异常家族:

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

最后那行是这篇文章里最重要的一行。结果是字节,不是字符串,而 Python 只扶你走它该扶的那一段:打印这个对象会给你看 b'...' 表示,试图把它粘到字符串上则会抛出 TypeError。一旦你想要真正的文本,决定由你来做,下面的字符集章节会讲到:什么时候这个决定轻而易举,什么时候它是一个陷阱。

可选的 altchars 参数会把标准字母表里的 +/ 换成另一对字符。正是这个旋钮制造出 URL 安全方言,urlsafe_b64decode 就是在 b64decode 之上这么搭出来的。你自己很少会去碰 altchars,但知道这台机器在那里总归是好事。除此之外,这个函数只是干脆利落地把活儿干完,很快,用 C。

默认宽容,按需严格

默认情况下,b64decode 是个礼貌的健忘鬼。解码开始之前,任何不在 64 字母字符表里的字符(也不在你的 altchars 里)都会被悄悄扔掉,活下来的部分照常解码。没有警告,没有提示,没有可供检查的返回值,只有一个结果。这份宽容有一位高贵的祖先:RFC 2045 第 6.8 节告诉解码器"所有换行符以及未在表 1 中出现的其他字符都必须被忽略",因为 SMTP 历来会把长行折行,并沿途撒落一些杂字符。一份穿越过邮件客户端、聊天应用或 PDF 复制的载荷,往往不用任何准备就能解码成功,这确实是一项超能力。

同样的好心,也正是默认解码器当不了校验器的原因。RFC 4648 第 12 节把风险写得明明白白:忽略非字母字符、而不是拒绝整个编码,会打开一条可用于泄露信息的隐蔽信道,还会破坏字符串相等性检查,因为两个不同的输入可以解码成相同的字节。凡是你自己没有编码的东西,都传入 validate=True,并把异常当作答案。下面是损害报告,每一行都能在任何现代 Python 上复现:

输入什么 宽容(默认) validate=True
Zm9vYmFy(干净的载荷) b'foobar' b'foobar'
Zm9v\r\nYmFy(中间有一个换行) b'foobar' binascii.Error
Zm9v YmFy (多余的空格) b'foobar' binascii.Error
Zm9v!YmFy(一个落单的感叹号) b'foobar' binascii.Error
junkZm9vYmFy(载荷前面有个单词) b'\x8e\xe9\xe4foobar' b'\x8e\xe9\xe4foobar'
Zm9v=YmFy(中间有个填充) b'foobar' binascii.Error
=Zm9v(填充跑到了最前面) b'foo' binascii.Error
====(四个填充,没有数据) b'' binascii.Error
(空输入) b'' b''

看着宽容列默默干活。最先让人吃惊的是开头带单词的那一行:junk 的四个字母恰好都在 Base64 字母表里,所以这份"垃圾"被解码成三个实打实的字节,还一本正经地粘在你的载荷前面。严格模式在这行上救不了场,因为输入确实是合法的 Base64;被直接拒掉的是其他那些行,而拒绝只有一种形状:一个 binascii.Error,携带着少数几条过目不忘的信息之一:

  • Incorrect padding - 丢弃之后长度不是四的倍数,或者最后一组太短。像 Zm9vYmE 这样一点填充都没有的字符串会落在这里。
  • Invalid base64-encoded string: number of data characters (N) cannot be 1 more than a multiple of 4 - 输入恰好比下一组少一个字符。这是被截断或被复制粘贴弄丢字符的载荷的经典指纹。
  • Only base64 data is allowed - 一个非字母字符活进了严格模式,哪怕只有一个换行也算一个。
  • Excess padding not allowed - 填充出现在字符串中间,或者填充比最后一组允许的更多。
  • Leading padding not allowed - 字符串以 = 开头。
  • 还有一条来自另一个家族:ValueError: string argument should contain only ASCII characters,当你在字符串里传入了非 ASCII 字母时就会得到它。字符串是接受的,但只接受 ASCII 字符串。

在幕后,validate=True 根本不是另一条代码路径。模块把这个标志作为 strict_mode 参数转发给 binascii.a2b_base64,而严格检查正是 Python 3.11 加入 binascii 的那个。当你想要严格、又不想经过 base64 这一层时,就有了一个直接的把手:

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

在你盲目信任严格模式之前,有一个怪癖必须钉死:它连一个结尾换行都拒收,所以 MIME 折行的块是宽容路径或 decodebytes 的活儿,不是 validate=True 的活儿。把严格路径留给那些你预期完美干净的数据,比如刚从你自己代码里新鲜出炉的令牌。

base64url:装得进 URL 的字母表

标准字母表里有两个 URL 讨厌的字符。+ 号会被任何表单解码器读成空格,/ 号则被保留给路径分隔符。RFC 4648 第 5 节定义了那个表亲方言:+ 变成 -/ 变成 _,并且只要数据长度能从上下文得知,填充就一并丢掉。RFC 甚至给了这个变体一个正式名字,base64url,并坚持它不应该被简称为"base64"。你最多会在 JSON Web Token 里遇到它,令牌的每一部分都是不带填充的 base64url;它也会出现在 OAuth 令牌和 API 游标参数里。

Python 为它配了专用函数 urlsafe_b64decode。它先把破折号和下划线翻译回加号和斜杠,再解码,但它不会替你补填充。不带填充的输入正是 JWT 的常态,所以算术行要放在前面,而它和 PyJWT 这类库在底层用的是同一行:

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

表达式 "=" * (-len(segment) % 4) 看起来像个小技巧,但它就是全部活儿:它产生零、一或两个填充,绝不超过两个,所以已经带填充的字符串会原样通过。负数取模正是它对付所有长度字符串的诀窍,也是每个 Python 开发者迟早都要敲一遍的那行 Base64 算术。

接下来是危险的混淆,因为两种字母表长得太像了。把 base64url 字符串喂给标准解码器,那些破折号和下划线根本不在标准字母表里,于是宽容解码器把它们吞掉,解码剩下的东西。对某些载荷,那是一股被搅乱的字节流;对另一些,则什么都不是:

import base64
tricky = base64.urlsafe_b64encode(b"\xfb\xff\xfe")
print(tricky)
# b'-__-'
print(base64.standard_b64decode(tricky))
# b'' - 每个字符都被悄悄丢掉了
print(base64.urlsafe_b64decode(tricky))
# b'\xfb\xff\xfe'

反方向倒是宽容的,这正是混淆不被察觉的原因:urlsafe_b64decode 先转换自己的字母表,再宽容地解码,所以它会欣然接受带着 +/ 的标准字母表字符串。教训不是靠手感来。教训是每个方言只挑一个函数,然后守定它,就像对待外币:在哪种货币有效就在哪里花,别跑错兑换柜台。

输出是字节:字符集这件事

有句话能了结人们带给 Base64 的一半字符集疑问:b64decode 解码的是字节,不是文本。没有字符集参数,没有任何转换,输入里也没有任何东西告诉 Python 这些字节应该是什么意思。意思必须由你从上下文供给,而这个上下文几乎总是下面三种之一:一个说明它的头部,一份说明它的 API 约定,或者一个藏在字节本身的魔数。

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

同样的想法,配上错误的标签,就是一次响亮的失败,而这反而是幸事。不是合法 UTF-8 的字节拒绝变成字符串,异常会精确地告诉你哪个字节冒犯了它:

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

三条经验法则能让这一节不至于变成恐怖故事。一:解码后的数据如果是 JSON,你完全不需要手动解码,因为 json.loads 从 Python 3.6 起就直接接受 bytes,而且自己会探测 UTF-8、UTF-16 和 UTF-32。二:二进制不是文本,所以对一个 PNG 来说,"探测出"的字符集是撞大运的猜测,而不是事实;去查字节,别查标签。三:发送方如果告诉了你字符集,相信发送方,因为 content-type 头部或 API 文档永远排在任何探测器前面,每一次都是。

解码后的 Base64 出现在 Python 代码的哪些地方

日子久了,你开始认得这些形状。这是一份野外指南,讲解码后的 Base64 会在 Python 应用里出现在哪些地方,以及各自的单行配方。接下来的章节会把最常见的几种完整讲一遍:

你在哪里发现它 它是什么 怎么读它
一个 JWT 头部、载荷和签名部分(RFC 7519) 按点号切开,urlsafe_b64decode 加填充修复
一个 Authorization 头部 HTTP Basic 凭据,user:pass(RFC 7617) 去掉 Basic 前缀,解码,按第一个冒号切开
一个 data: URI HTML 或 CSS 里的内联媒体(RFC 2397) 在第一个逗号处切开,解码剩下的部分
一个邮件附件 一个 Content-Transfer-Encoding: base64 正文(RFC 2045) 对消息部件调用 get_payload(decode=True)
一个邮件头部值 一个 =?charset?b?...?= 编码词(RFC 2047) email 包替你解码
一个 PEM 文件 一个带护甲的密钥或证书(RFC 7468) 去掉护甲行,把正文解码成 DER
一个 JSON API 字段 伪装成字符串混进来的二进制 解码,然后把结果当字节,别当文本
一个 TEXT 列或环境变量 存在纯文本地里的二进制或 JSON 解码,然后解析或写入,用你们约定好的字符集

阅读一个 JSON Web Token

一个 JWT 是三段用点号连起来的 base64url:头部、载荷和签名。前两段是纯 JSON,所以往里一瞥各只需一行,用上一节的填充修复:

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'}

范围说明一句,因为这事要紧:这样检查令牌是调试工具,不是认证机制。载荷读得出来,不代表它是真的;攻击者可以伪造前两段,而完全不需要知道你的密钥。要做真正的验证,就把令牌交给 PyJWT(pip install pyjwt),它会检查签名,并且在没有显式算法列表时拒绝解码:

import jwt
# 短于 32 字节的密钥会招来 PyJWT 的 InsecureKeyLengthWarning(PyJWT 2.11+),对演示密钥来说算是一句合理的抱怨。
decoded = jwt.decode(token, "super-secret-key", algorithms=["HS256"])
print(decoded)
# {'sub': '1234567890', 'name': 'John Doe'}

用错密钥时,你得到的是异常而不是字典,而这正是生产代码里你想要的行为。如果令牌带着过期的时间戳到达,PyJWT 也会为此抛出异常,所以你永远不用自己去记那些声明的名字。

打开一个 data URI

data URI 把媒体直接嵌进 HTML 或 CSS,让浏览器不必再发第二个请求:data:、媒体类型、单词 base64、一个逗号,然后是编码后的字节。切分点在第一个逗号,句号,它后面的一切就是纯标准字母表的载荷:

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'

结果开头那 8 字节的 PNG 签名,是一个便宜又开心的小检查,确认你解码对了东西。两个坑值得提一提。如果 URI 来自抓取的页面或聊天消息,先剥掉 HTML 实体和多余空白,因为宽容解码器会原谅一大堆垃圾,递给你一个损坏的图片而不是一个错误。而如果你要批量解码不受信任的输入,就传入 validate=True:一个过不了严格校验的 data URI,本来就不是格式良好的,你不想凭感觉把它写进磁盘。

拆开 Authorization 头部

Basic 认证(RFC 7617)是 HTTP 里最老的方案,至今仍然支撑着数量惊人的一批 API 集成、webhook 和 CI 流水线。客户端把凭据作为 user:pass 进行 Base64 编码,放在单词 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

注意那个 partition,它是日后救你的细节:密码里可以含冒号,用户 ID 里通常没有,而且只有第一个冒号才是分隔符。一句坦率话,因为 RFC 自己就说得很直白:Base64 不是加密。RFC 4648 说,base 编码"在视觉上隐藏了本容易辨认的信息,例如密码,但不提供任何计算意义上的保密性"。一个 Basic 头部能被任何看到流量的人解码,所以把它当作 TLS 保护连接上的便利,而不是安全边界。当你自己是发送头部的一方时,requests 可以用 auth=("jane", "pa:ss") 替你构建它,只要这个库已经在你技术栈里,就值得用。

邮件:最初的客户

Base64 在 1993 年被标准化,就为了一个活儿:让二进制在邮件里活下去。RFC 2045,也就是 MIME 标准,定义了 Content-Transfer-Encoding: base64 正文编码,直到今天它仍然是附件穿越互联网的默认方式。Python 的 email 包把整份活儿都替你干了:它解析头部,解码 RFC 2047 藏在头部字段里的 =?utf-8?b?...?= 编码词,你开口时它还会把正文做 Base64 解码:

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'

get_payload(decode=True) 这个调用会读取 Content-Transfer-Encoding 头部并替你 Base64 解码正文,沿途把 76 字符的行拆开。policy=policy.default 参数选择现代接口,它从 Python 3.6 起可用,那时新的基于策略的 email API 结束了试验期,解码后的头部值开箱即得;遗留解析器依然能跑,只是你得亲手解码编码词。只有当你解析的不是完整消息、而是一段裸片段时,比如某人贴进工单里的一块内容,你才需要降落到 decodebytes。对于多部件消息,用 iter_attachments() 迭代,给每个部件同样的单行待遇。

PEM 护甲与 cryptography 包

一个 PEM 文件就是一行头、几行折行后的 Base64 和一行尾,再无其他。护甲只是装饰,Base64 才是全部故事,因为它解码出来就是底下的原始 DER 结构。cryptography 包(pip install cryptography)能直接加载这个结果,这正是它成为一切涉及证书和密钥的标准工具的原因:

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

在大多数生产代码里,你永远不需要亲手做拆护甲加解码:load_pem_x509_certificate 接受带护甲的字节,在底层替你处理 Base64 这一步。手动路径发挥价值的时候,是 DER 字节已经在手里(一个数据库列、一个配置文件、协议里的字节缓冲区),或者这块内容被包在字符串里到达、你想在信任它之前先看看里面的时候。密钥的方式完全相同,load_der_private_key 就等在同一次解码的另一侧。

文件、魔数与 .b64 习惯

解码只是半个活儿,字节通常想变成一个文件。套路是读、解码、检查、写,而检查很重要,因为一个损坏的载荷否则会悄悄产出一个错误的文件,几周之后你才会发现:

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)

对于快速的一次性转换,遗留的文件到文件函数一次调用就跑完全程,折行也一并处理:

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

说真的,你刚解码出来的是什么?几乎每种常见格式的前几个字节都是固定签名,而因为 Base64 是确定性的,编码后的签名也是固定的。看到这些前缀之一,就像在远处认出了一块车牌:

Base64 以什么开头 它大概是
iVBORw0KGgo 一张 PNG 图片
/9j/ 一张 JPEG 图片
R0lGODlh 一张 GIF 图片
JVBERi0 一份 PDF 文档
UEsDBA== 一个 ZIP 压缩包
UklGRg== 一个 RIFF 容器(WAV、WEBP、AVI)
LS0tLS1CRUdJTg== 一个 ASCII 护甲块("-----BEGIN ...")

趁文件还在写,把尺寸算术也做了,因为正是这个数字在磁盘写满时让人吃惊:编码会让数据膨胀大约三分之一,所以一个 300 KB 的文件会以约 400 KB 的 Base64 文本上路,而你解码回来的文件是较小的原始尺寸。你的磁盘,以及你一次读入整个文件时的内存,都应该为这个差值留好预算。

数据库、配置文件与环境变量

Base64 是走私二进制(或 JSON)穿过只接受文本的存储的惯用手段:一个 TEXT 列、一个 .ini 文件里的值、一条部署流水线里的环境变量。解码配方和文件一样,只是少了磁盘这一步:

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

这个角落有两条备注。当存下的值是 JSON 时,跳过中间的 .decode("utf-8") 步骤,让 json.loads 直接收 bytes,它从 Python 3.6 起就这么干了。还有一句诚实的警告,因为整篇文章里最昂贵的误解就住在这里:环境变量或配置文件里的 Base64,是挡住瞟一眼文件的人的盾牌,挡不住真正读它的人。如果这个值真的敏感,先加密(cryptography 包自带 Fernet,正是为此而生),然后只有当你的存储要求文本时,再对密文做 Base64。

当载荷分片到达

标准库里没有增量式的 Base64 解码器:没有 update 加 finish 的一对函数,所以流式数据需要你自己做一点簿记。算术很简单,同时又很严格。四个编码字符构成三个字节,所以你只能解码完整的四字符组,而必须把余数带到下一个块去:

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)

喂给它一个套接字缓冲区、一个按 64 KB 分块读取的文件,或一个剥掉了换行符的行生成器,输出都与一次性解码整个东西完全相同。如果你的输入保证干净且未折行,就用 validate=True 解码每个完整组来保持严格,并记住最后的余数可能需要填充修复,这正是这个助手在最后解码之前补上它的原因。这是编码那一侧用的同一种接缝逻辑,只是接缝从三个字节换成了四个字符。

从命令行

base64 模块还能当一个小巧的命令行工具用,当载荷躺在你的终端里而不是代码里时,这很方便。默认是编码;-d(或它的孪生兄弟 -u)负责解码:

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

你不给它文件时它从 stdin 读,给了就从你点名的文件读,而底层就是那个遗留的文件到文件接口,所以输出以 76 字符折行,每行带一个结尾换行。想在会话里粘贴载荷并把严格程度拉满时,解码器的单行版本是个好习惯:

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

九个容易翻车的坑

Python 里每一个 Base64 解码 bug 都是下面这些之一。把这张清单存在你恐慌时找得到的地方,因为它比你今年读到的任何其他单篇文档都救过更多的下午。前三个配了代码,因为见过残骸之后它们更好记:

缺失的填充。最常见的一次崩溃,通常是因为一个 JWT 分段或一个 API 值没有带着它的填充到达:

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'

被截断的字符串。当错误说数据字符的数量"cannot be 1 more than a multiple of 4"时,载荷在途中被截断了,或是一次复制粘贴在末尾丢了字符。多少填充都救不了长度对四取余为一的字符串,数据就是不在那里,诚实的答案是再要一次载荷。

静默的垃圾。宽容模式会解码任何幸存的东西,而普通的英文单词里全是 Base64 字母表字符,所以载荷前面的一个落单单词会变成粘在你数据上的真实字节:

import base64
print(base64.b64decode("junkZm9vYmFy"))
# b'\x8e\xe9\xe4foobar' - 三个纯虚构的字节,然后才是真相

另外六个完全不需要代码:

  • 你用标准解码器解码了一个 base64url 字符串。破折号和下划线不在标准字母表里,于是它们悄悄消失,载荷出来时被搅乱了,或者干脆是空的。用 urlsafe_b64decode 加填充修复。
  • 你忘了结果是字节。把它粘到字符串上会抛出 TypeError,把它推进 JSON 响应则会序列化出 b'...' 表示。在边界处调用 .decode(encoding),刻意地,用你真正想要的那个编码。
  • 你传入了非 ASCII 字符串。解码器接受字符串,但只接受 ASCII 的,其他一律 ValueError。如果你的载荷出自一个用错误编码读取的文本文件,修读取,别修解码。
  • 你解码了两次。数据在上游已经被解码过了,或者它是 Base64 套 Base64,第二遍把你的密码变成了六个人类永远读不了的字节。
  • 你对折行数据用了严格模式。一个换行就足以让 validate=True 抛异常,所以 MIME 块和 PEM 正文属于宽容工具,不属于严格的那个。
  • 你相信了一个中间的填充。在宽容模式下,字符串任何位置的 = 都会被悄悄丢弃,所以一个填充放错位置的损坏载荷可以解码出"正确"的答案。只有严格模式会注意到,而它注意到的方式就是拒绝。

如果你的工作就是当看门人,这里有一个小助手,把两种脾气一起用起来:先严格,再填充修复,两者都不行就响亮地失败:

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'

注意这个助手依然信任你告诉它要信任的字母表。如果你的输入可能是 base64url,就改喂给 urlsafe_b64decode。校验是一份契约,而契约说明了数据用的是哪种方言。

一个安静模块的三十年

这个模块在标准库里住了四分之一世纪,大部分时间都纹丝不动。它偶尔挪动时,动作虽小却真实,并解释了老论坛里流传着的一些"在我机器上没问题"的故事:

  • 1995 - Jack Jansen 重写 base64.py,把真正的活儿委托给 C 层的 binascii 模块。那条注释还在文件里,这种委托到今天依然成立。
  • 2003,随 Python 2.4 发布 - Barry Warsaw 加入了完整的 RFC 3548 支持:b16b32b64 家族,加上你今天还在用的 standard_*urlsafe_* 变体。
  • Python 3.1 - encodestringdecodestring 被弃用,让位给 encodebytesdecodebytes,也就是留下来的那两个名字。
  • Python 3.3 - 解码函数开始接受 ASCII 字符串,结束了每一次解码都必须从 bytes 字面量开始的年代。
  • Python 3.4 - 任何地方都接受 bytes 类对象(memoryview 在内),Base85 表亲 a85b85 也加入了模块。
  • Python 3.9 - 被弃用许久的 encodestringdecodestring 终于被移除。还调用它们的旧教程只需一个词的重命名。
  • Python 3.10 - b32hexencodeb32hexdecode 带着扩展十六进制字母表到来,就是那个让编码数据保持字典序可排序的字母表。
  • Python 3.11 - binascii.a2b_base64 获得了 strict_modevalidate=True 底层骑的就是它。
  • Python 3.13 - z85encodez85decode 把 ZeroMQ 的 Z85 方言带进标准库,古老的 uu 模块则在 PEP 594 之下被移除,附了一句毫不含糊的注记:改用 base64
  • Python 3.14 - b16decode 快到了最多六倍:它的校验现在跑在 bytes.translate 上而不是正则表达式,模块也再不需要导入 re。它的导入时间同样登上了改进模块的榜单。

这一切都不改变函数做什么,而这正是一个如此年长的模块的安静奢侈:2005 年解码 Base64 的代码,2026 年还在解码它,同一行,同样的结果。

边角料里的小乐趣

正经活儿干完了,来欣赏这个模块藏在边角里的小乐趣:

  • 模块自己的文档做了十多年同一个演示:b'data to be encoded' 进去,b'ZGF0YSB0byBiZSBlbmNvZGVk' 出来。如果你读过过去二十年里任何一版 Python 的 base64 页面,你都见过这对组合。
  • 单词 junk 是一个完全合法的 Base64 字符串。四个字母全在字母表里,所以载荷开头的落单单词会变成三个虚构字节而不是错误,宽容模式的绰号也由此而来。
  • urlsafe_b64decode 意外地双语。它先转换自己的字母表,再宽容地解码,所以它也能读带着 +/ 的标准字母表字符串。一个函数,两种方言,零抱怨。
  • 错误信息是一套稳定的小词典,自从 C 实现以来就没挪过:Incorrect paddingOnly base64 data is allowedExcess padding not allowedLeading padding not allowed。学会它们,你不用跑一行代码就能给损坏的载荷做分诊。
  • 空字符串是唯一得不到任何反应的输入:b'' 进,b'' 出,两种脾气都一样。没有东西进来,没有东西出去,没有警报。
  • 模块的 docstring 至今还写着 RFC 3548,也就是 2003 年版的规范。RFC 4648 从 2006 年起就是现行标准,模块忠实地跟着它,只是懒得更新那句话。
  • Python 2 的解码侧没有类型墙:普通 str 进去,普通 str 出来。Python 3 开发中 2007 年的 bytes 大改造改变了这一点,而大多数"为什么我的解码坏了"帖子至今指向的还是那些旧 Python 2 教程。

所以,整套哲学浓缩成四条规则。凡是你自己没有编码的东西,都传入 validate=True,把异常当作真实的答案,而不是建议。知道你手里拿的是哪种方言,标准、base64url 还是 MIME 折行,因为解码器不会告诉你,它只会通过丢掉不合身的东西来猜。在证明结果是文本之前,一直把它当字节,然后问一问字符集归谁所有。还要记住,这个函数最友好的特性,愿意解码那些并不完全是 Base64 的东西,正是让它危险的同一个特性,所以每一次调用都要决定,输入挣得了多少信任。

如果哪天你需要走反方向,把新鲜的字节重新包回那条友好的字母缎带,为了一个令牌、一个附件或一张内联图片,b64encode 的全部故事在本页底部那篇相关的 Base64 编码文章里讲得细细致致。两个方向互为镜像,但各有各的意外,而你现在已经把这一边背得滚瓜烂熟。祝解码愉快。

最后更新: 2026-09-08

相关文章: Python 中的 Base64 编码:完整指南