需要使用 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 支持:b16、b32 和 b64 家族,加上你今天还在用的 standard_* 和 urlsafe_* 变体。
  • Python 3.1 - encodestring 和 decodestring 被弃用,让位给 encodebytes 和 decodebytes,也就是留下来的那两个名字。
  • Python 3.3 - 解码函数开始接受 ASCII 字符串,结束了每一次解码都必须从 bytes 字面量开始的年代。
  • Python 3.4 - 任何地方都接受 bytes 类对象(memoryview 在内),Base85 表亲 a85 和 b85 也加入了模块。
  • Python 3.9 - 被弃用许久的 encodestring 和 decodestring 终于被移除。还调用它们的旧教程只需一个词的重命名。
  • Python 3.10 - b32hexencode 和 b32hexdecode 带着扩展十六进制字母表到来,就是那个让编码数据保持字典序可排序的字母表。
  • Python 3.11 - binascii.a2b_base64 获得了 strict_mode,validate=True 底层骑的就是它。
  • Python 3.13 - z85encode 和 z85decode 把 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 padding、Only base64 data is allowed、Excess padding not allowed、Leading 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 编码:完整指南