做数据分析的人,十有八九都被Matplotlib的中文乱码折磨过一次。辛辛苦苦画出来的图,标题、坐标轴标签、图例只要带上中文,就集体变成一个个小方框,有些场景下甚至连方框都不给,直接显示成空白。我第一次遇到这个问题的时候,第一反应是编码不对,于是把文件头改成utf-8,又给源码加了一堆编码声明,折腾了半天毫无起色。后来才搞明白,这种事和编码没有半点关系,真正的根源在Matplotlib的字体体系上——默认情况下,它能用来渲染文字的字体列表里,根本没有中文字体。
这篇文章把我踩过的坑和最终解决的完整路径整理出来,包含原理、排查步骤、三种修法和几个必然遇到的连带问题。不管你是刚入门的新手,还是在服务器上部署绘图脚本的老手,照着操作都能彻底解决中文显示问题。
1. 乱码根因:Matplotlib的字体回退机制
1.1 乱码的三种典型形态
我见过不少朋友发图求助,乱码的形态其实不止一种。最常见的是豆腐块,也就是一个个空心矩形;第二种是直接空白,文字区域什么都没有;第三种是乱码字符,比如出现一些莫名其妙的繁体字、生僻字或者特殊的符号。前两种是Matplotlib找不到能渲染中文字符的字形,最后一种往往是字体本身包含的字形和你想表达的字不匹配,或者系统字体被某些工具污染了。
不同形态对应的问题不完全一样,但绝大多数情况都是前两种。判断方法也很简单:看英文和数字能不能正常显示。在同一张图里,如果英文、数字都正常,只有中文出问题,那就基本可以断定是字体缺字,而不是编码问题。这个结论非常重要,它可以帮你省掉大量无意义的排查时间。
1.2 默认字体列表为什么容不下中文
Matplotlib渲染文本时,会在一组"候选字体"中逐个查找,看哪个字体包含当前字符对应的字形。这个候选字体列表存在配置文件里,默认是DejaVu Sans这类西文字体,外加一些类似Bitstream Vera Sans、Arial的备选。问题在于,这些字体设计的初衷是覆盖拉丁字母、希腊字母、数字和常用符号,它们的字形表里根本没有汉字所在的Unicode区间。
我最初以为Matplotlib会自动调用操作系统里已安装的中文字体,实测下来完全不是这样。Matplotlib启动时会扫描一部分系统字体目录,并把扫描到的字体登记到内部的fontManager里,但登记归登记,它不会自作主张把中文字体加进默认的候选列表。也就是说,哪怕你Windows系统里装了微软雅黑,macOS里装了苹方,Matplotlib默认也不会用它们去渲染中文。它只会沿着默认列表走一遍,所有字体都渲染不了汉字,最后丢给你一个方框。
1.3 一个核心结论:改字体而不是改编码
我后来在某高校的Python课上帮一个A同学排查问题,他也是死活画不出中文,代码里文件头和打印输出都用了utf-8,甚至把操作系统区域设置都换了,还是满屏方框。我让他直接在脚本里加一行指定字体,问题当场消失。这件事给了我一个很深的印象:Python 3的字符串本身已经是Unicode,数据从文件读进来、写出去都不会丢,真正卡住的是最后一步——渲染引擎能不能找到对应的字形。
所以结论很简单:解决中文乱码,核心是让Matplotlib使用一个包含中文字形的字体,而不是去折腾编码。看清这一点之后,后面的所有操作都是围绕"如何把合适的中文字体交给Matplotlib"展开的。
2. 动手前先确认:系统里到底有哪些中文字体
2.1 查看系统字体能不能反手帮你
不要一上来就改配置,先看看操作系统里有没有可用的中文字体。Windows系统一般自带黑体、微软雅黑、宋体;macOS自带苹方、华文黑体、Hiragino Sans GB;很多Linux发行版自带文泉驿正黑,也有一些装了思源黑体。需要特别注意的是,字体文件存在不代表Matplotlib认得,但系统这个层面连文件都没有,后面就白忙活了。
查看方式各系统不太一样。Windows可以在设置里看字体,也可以直接打开C:\Windows\Fonts目录;macOS可以用"字体册"应用,或者去/System/Library/Fonts、~/Library/Fonts目录看;Linux可以在终端执行fc-list :lang=zh,这个命令会列出所有支持中文的字体。我个人最推荐在Linux和macOS上用fc-list,信息非常直接。Windows上也可以用PowerShell跑一段命令,但不如直接看Fonts目录直观。
2.2 检查Matplotlib已识别的字体
系统层面有字体之后,第二步是确认Matplotlib到底识别了哪些。用一段很短的代码就能查:
from matplotlib import font_manager fonts = {f.name for f in font_manager.fontManager.ttflist} cn_keywords = ["Hei", "YaHei", "PingFang", "WenQuanYi", "Noto Sans CJK", "Source Han"] matched = [f for f in sorted(fonts) if any(k in f for k in cn_keywords)] print(matched)如果输出是空列表,说明Matplotlib没有扫描到任何中文字体。这时候要检查字体目录是否在扫描范围内,或者干脆跳过这一步,直接采用后面我讲的"指定字体路径"方法。如果输出里有一些名称,比如"SimHei"、"Microsoft YaHei"、"PingFang SC",那说明字体文件已经登记在案,接下来只需要在配置里把它加入候选列表即可。
我习惯把这一步叫作"字体体检"。它虽然不能直接解决问题,但能让你少走很多弯路。有一次我在一台刚装的Linux服务器上排查问题,系统里明明有文泉驿字体,但Matplotlib就是不用,执行上面的代码才发现ttflist里根本没有它。后来查明是字体文件权限不对,Matplotlib进程读不到。
2.3 五分钟复现一次乱码
建议你先把问题复现出来,后面改完配置可以对比效果。一段最简代码就行:
import matplotlib.pyplot as plt plt.plot([1, 2, 3], [1, 4, 9]) plt.title("中文标题测试") plt.xlabel("横轴名称") plt.ylabel("纵轴名称") plt.show()正常运行后,如果标题和坐标轴文字出现方框,说明问题复现成功。把这段代码留好,后面每一步配置改完都跑一次,能非常直观地验证是否生效。我强烈建议不要只改配置凭感觉判断,一定要用真实图片确认,因为有时候配置改错位置或者被其他设置覆盖,你以为解决了,下一张图又原形毕露。
3. 按场景选择的三种修法
3.1 临时调试最稳的方式:指定字体路径
如果你不想动全局配置,只是临时画一张图,我推荐直接指定字体文件路径。这种做法的好处是彻底绕开Matplotlib的字体扫描机制,管它能不能被识别,你告诉渲染引擎"就用这个文件"。
import matplotlib from matplotlib import font_manager font_path = "C:/Windows/Fonts/simhei.ttf" # Windows 黑体 font_manager.fontManager.addfont(font_path) font_name = font_manager.FontProperties(fname=font_path).get_name() print(font_name) matplotlib.rcParams["font.sans-serif"] = [font_name, "DejaVu Sans"] matplotlib.rcParams["axes.unicode_minus"] = Falseaddfont是Matplotlib较新版本提供的接口,作用是把字体文件动态注册进fontManager。注册完之后,用FontProperties从同一个文件取出来font_name,这个名字才是Matplotlib内部认识的名字。有人喜欢直接写"SimHei"之类的中文名,但如果你不确定字体文件对应的内部名是什么,用get_name()是最保险的。macOS上比较常写的路径是/System/Library/Fonts/PingFang.ttc,注册后拿到的名字通常是"PingFang SC"。Linux上如果有Noto Sans CJK,路径一般在/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc。
这种方式把字体文件当作"一次性原料"注册进当前进程,一旦脚本重启,它又会被遗忘。所以适合快速验证、临时绘图,不适合长期项目。
3.2 日常全局切换:rcParams配置
用得最多的其实是rcParams这种配置方式。它不需要每次重新注册字体文件,前提是目标字体已经被Matplotlib扫描到。你只要在脚本开头把所有需要的中文字体名称写进候选列表:
import matplotlib.pyplot as plt plt.rcParams["font.sans-serif"] = ["SimHei", "Microsoft YaHei", "PingFang SC", "WenQuanYi Zen Hei", "DejaVu Sans"] plt.rcParams["axes.unicode_minus"] = False这段配置的意思是:当需要渲染sans-serif字体时,按顺序在候选字体里找,先试黑体,不行试微软雅黑,再不行试苹方,直到找到能显示当前字符的字体为止。我这么组合的原因很现实:同一份脚本可能要跨Windows、macOS、Linux跑,不同系统可用的中文字体不一样。把多套系统的字体都塞进去,哪个系统能匹配就用哪个,灵活性最高。
使用rcParams的前提是上面"字体体检"那一步确认字体已经被识别。如果出现"findfont: Font family 'xx' not found"的警告,说明候选字体列表中有一个或多个字体名不在fontManager的登记表里,这时候就要回到3.1,把对应字体文件addfont进去,或者换一个能被识别的字体名。
3.3 一劳永逸:修改matplotlibrc配置文件
rcParams的方案每写一个脚本都要复制粘贴一遍,虽然简单,但还是不够省心。更推荐的做法是直接修改Matplotlib的配置文件matplotlibrc,让所有脚本启动时自动加载中文字体配置。
先定位配置文件位置:
import matplotlib print(matplotlib.matplotlib_fname()) print(matplotlib.get_configdir())第一个输出是配置文件路径,第二个是配置目录。找到文件后,编辑它,找到下面这几行:
#font.family: sans-serif #font.sans-serif: DejaVu Sans, Bitstream Vera Sans, Lucida Grande, Verdana, Geneva, Lucida, Arial, Helvetica, Avant Garde, sans-serif #axes.unicode_minus: True把注释去掉,改成:
font.family: sans-serif font.sans-serif: SimHei, Microsoft YaHei, PingFang SC, WenQuanYi Zen Hei, DejaVu Sans axes.unicode_minus: False保存之后,重启Python内核再运行绘图脚本,配置就会全局生效。这样以后新建的脚本不需要再写任何rcParams,直接画图就是中文。我通常会把SimHei、Microsoft YaHei、PingFang SC、WenQuanYi Zen Hei这组字体名固定写在前面,再兜底一个DejaVu Sans,保证任何系统上都能找到可用字体。
有一点必须提醒:matplotlibrc的解析是按块来的,如果你在脚本里用plt.rcParams覆盖了配置,以脚本里的为准。而且matplotlibrc只改配置文件还不够,如果Matplotlib的字体缓存里没有目标字体的记录,配置了字体名也没用。所以最稳的配合方式还是"先addfont注册,再改rcParams或matplotlibrc"。
4. 两个容易被忽略的连带坑
4.1 负号变方块:axes.unicode_minus
只解决了中文还不够,我发现很多人紧接着会遇到第二个问题:坐标轴上的负号变成方块。这是Matplotlib的一个历史设计。默认情况下,Matplotlib渲染负号用的是Unicode字符集里的"真正的负号"(U+2212),而不是普通键盘上的减号连字符(U+002D)。DejaVu Sans这套默认字体恰好包含了这个字符,所以默认状态下负号显示正常。
问题在于,一旦你换成SimHei或微软雅黑,这些中文字体的字形表里不一定包含U+2212,于是原本正常的负号反而变方块。解决方案就是前面反复出现的那一行:
plt.rcParams["axes.unicode_minus"] = False它的作用很简单:告诉Matplotlib用ASCII的连字符号来渲染负号,不碰Unicode负号。这行配置我建议和字体配置永远绑定在一起,不管在文件里还是代码里,写成一对。我最开始不懂这个原理,中文字体换好后负号又出问题,还以为字体配置有冲突,白折腾了很久。
4.2 字体缓存不刷新
Matplotlib内部有一层字体缓存机制,它会把扫描到的字体文件信息保存到本地缓存目录,用来加速下次启动。这个机制本身不复杂,但有个烦人的副作用:当你往系统里新装了一个中文字体,或者手动更新了字体文件,Matplotlib可能还拿着旧的缓存信息,导致新字体怎么都不生效。
遇到这种情况,先找到缓存目录:
import matplotlib print(matplotlib.get_cachedir())然后把这个目录下的文件删掉,重启Python内核,Matplotlib会重新扫描字体目录并重建缓存。不同版本的操作系统上缓存位置略有差异,有的在用户目录下的.matplotlib,有的在.cache/matplotlib,以get_cachedir()的输出为准。删除缓存不影响任何数据,最多让Matplotlib启动时慢几秒,因为要重新扫描一次。
我个人的习惯是:新增字体、修改字体文件、或者跨系统搬运项目时,先清一次缓存。这个小操作能排除掉很多莫名其妙的问题,尤其是那种"照着配置写了但就是不生效"的情况。
4.3 字体名声明的顺序陷阱
font.sans-serif是一个列表,顺序是有讲究的。Matplotlib会从前往后逐个尝试,第一个能渲染目标字符的字体就被选中。所以原则上要把最想要的字体放前面,兜底字体放最后。
但这里有一个我踩过的坑:如果某个字体被安装了,但实际能渲染的字符集很有限,你把它放在列表最前面,结果不仅是中文,连部分英文都显示异常。这是因为Matplotlib认为这个字体已经有字形了,不会继续往后找。比如把某个特殊符号字体放在SimHei前面,图中普通的英文字母就会用它来渲染,风格看起来非常奇怪。所以列表的排列原则应该是:中文字体优先,但不要选那些字符覆盖范围过窄的字体顶在最前面。SimHei和微软雅黑这类覆盖拉丁、中文、常用标点的字体,放在第一位基本是安全的。
另一个细节是"font.family"和"font.sans-serif"的关系。默认font.family是sans-serif,Matplotlib在渲染时会根据family去查找对应的serif或者sans-serif列表。如果你直接把某个中文字体名写进font.family,相当于把整条链路换了,很多渲染配置会受影响。所以我通常只改font.sans-serif,保持font.family不变,这样只会替换sans-serif候选字体,影响范围更可控。
5. 不同环境的字体清单与服务器部署
5.1 三个常用系统的中文字体对照
不同操作系统的字体名称和文件路径差异很大,我整理了一个简单的对照表,方便你在不同环境里快速查找。
| 系统 | 字体名(Matplotlib内部名) | 典型文件路径 | 备注 |
|---|---|---|---|
| Windows | SimHei | C:\Windows\Fonts\simhei.ttf | 黑体,系统自带,最常用 |
| Windows | Microsoft YaHei | C:\Windows\Fonts\msyh.ttc | 微软雅黑,现代感强 |
| Windows | SimSun | C:\Windows\Fonts\simsun.ttc | 宋体,适合正式文档 |
| macOS | PingFang SC | /System/Library/Fonts/PingFang.ttc | 苹方,macOS 10.11+ |
| macOS | Hiragino Sans GB | /System/Library/Fonts/Hiragino Sans GB.ttc | 冬青黑体 |
| macOS | STHeiti | /System/Library/Fonts/STHeiti Light.ttc | 华文黑体 |
| Linux | WenQuanYi Zen Hei | /usr/share/fonts/wenquanyi/wqy-zenhei/wqy-zenhei.ttc | 文泉驿正黑 |
| Linux | Noto Sans CJK SC | /usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc | 思源黑体 |
出现ttc后缀的文件时要注意,ttc是字体集合文件,里面可能包含多个字重或多个子字体。addfont之后,用FontProperties(fname=path).get_name()取到的名字,才是这个集合在Matplotlib里登记的名字。不同文件对应的内部名可能和你直觉里想的字体名不一样,务必用代码确认。
5.2 服务器上没装字体的处理
服务器环境是另一个高发区。很多生产环境里的绘图脚本跑在Docker容器或者精简版的Linux服务器上,这类环境往往什么都没装,fc-list执行结果几乎是空的。这种情况不要慌,思路很简单:没有字体就手动放一个进去。
你可以从开源渠道下载一个中文字体文件,比如Noto Sans CJK的otf或ttf,放到服务器上一个固定目录,比如/opt/fonts/。然后在绘图脚本里用addfont注册:
import matplotlib from matplotlib import font_manager font_manager.fontManager.addfont("/opt/fonts/NotoSansCJK-Regular.ttc") prop = font_manager.FontProperties(fname="/opt/fonts/NotoSansCJK-Regular.ttc") name = prop.get_name() matplotlib.rcParams["font.sans-serif"] = [name, "DejaVu Sans"] matplotlib.rcParams["axes.unicode_minus"] = False我一般不放系统目录,而是放在项目或用户目录里。这样做的原因有两个:第一,普通用户可能没有写入系统字体目录的权限;第二,把字体文件跟着项目走,迁移环境时不容易丢。如果你用Docker,直接在构建镜像时把字体文件COPY进去就行,这样每次启动镜像都自带中文字体,不需要额外处理。
5.3 一套可以复用的配置函数
为了避免每次写脚本都重复字体配置,我把这部分封装成了一个函数,放到一个公共工具文件里,哪个项目用到就直接调用:
import matplotlib from matplotlib import font_manager def setup_cn_font(preferred_fonts=None): font_paths = [ "C:/Windows/Fonts/simhei.ttf", "/System/Library/Fonts/PingFang.ttc", "/usr/share/fonts/wenquanyi/wqy-zenhei/wqy-zenhei.ttc", "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", ] for path in font_paths: try: font_manager.fontManager.addfont(path) except FileNotFoundError: continue fallback = ["DejaVu Sans"] candidates = preferred_fonts or ["SimHei", "Microsoft YaHei", "PingFang SC", "WenQuanYi Zen Hei", "Noto Sans CJK JP"] matplotlib.rcParams["font.sans-serif"] = candidates + fallback matplotlib.rcParams["axes.unicode_minus"] = False这段代码的思路是:先把多个常见系统里的中文字体文件路径全都尝试注册一遍,能注册哪个算哪个;然后把字体名候选列表写进rcParams,最后统一关闭unicode负号。在Windows上它会注册SimHei,在macOS上注册PingFang SC,在Linux上注册文泉驿或Noto,一套代码横跨三个平台。函数里用了try-except跳过不存在的路径,不会因为找不到文件而报错。
6. 从报错到验证:完整排障链路
6.1 读懂常见的三条警告
排查过程中,Matplotlib会输出一些日志,很多人看到英文警告就紧张,其实它们含义相当明确。
第一条是"findfont: Font family 'SimHei' not found",意思是候选列表里写了SimHei,但fontManager的登记表中找不到它。遇到这个警告,说明字体文件没有被扫描或注册,解决方式是先执行addfont,或者检查系统字体目录里有没有SimHei。
第二条是"findfont: Glyph missing from current font",这是指当前选中的字体里缺少某个字符对应的字形。更常见的表现就是方框、空白或问号。中文出现这个问题,说明你选的字体本身不支持中文,或者字体覆盖范围不够,需要换一个更完整的中文字体。
第三条是"Matplotlib is building the font cache using fc-list",这类日志一般出现在你删除缓存或首次启动时。它不是错误,只是提示Matplotlib正在重建字体缓存,耐心等它跑完就行。如果这步执行很久还没停,可能是字体目录里的文件太多或者有损坏的字体文件,需要检查一下字体目录。
6.2 一张图结束乱码问题
把上面的方法全部串起来,我给你一个我实测过多次的完整示例,直接复制就能用:
import matplotlib import matplotlib.pyplot as plt from matplotlib import font_manager font_manager.fontManager.addfont("C:/Windows/Fonts/simhei.ttf") matplotlib.rcParams["font.sans-serif"] = ["SimHei", "Microsoft YaHei", "DejaVu Sans"] matplotlib.rcParams["axes.unicode_minus"] = False fig, ax = plt.subplots(figsize=(8, 4.5)) x = [1, 2, 3, 4, 5] y1 = [2.5, 3.6, 4.1, 3.8, 5.2] y2 = [-1.2, -2.1, -1.8, -3.0, -2.6] ax.plot(x, y1, marker="o", label="营业数据") ax.plot(x, y2, marker="s", label="变化幅度") ax.set_title("2025年第一季度数据对比") ax.set_xlabel("月份") ax.set_ylabel("金额(万元)") ax.legend() ax.grid(axis="y", linestyle="--", alpha=0.6) plt.show()这个例子里既有中文标题、中文标签,也有带负号的数据,一次跑通可以同时验证字体和负号两个问题。如果你换到别的系统,把addfont那行的路径改成对应环境下的中文字体文件即可。
6.3 我总结的排查口诀
最后把这套排查逻辑压缩成几句口诀,方便你日后快速定位:
先看系统字体有没有,再查Matplotlib的ttflist认不认。临时解决用addfont指定路径,长期解决改matplotlibrc。负号和字体一起配,unicode_minus关了它。有了新字体不生效,删掉缓存再重启。最后拿张真实图做回归,确认中文和负号都正常。
这套流程我前前后后用了很多次,从Windows笔记本换到macOS,再把绘图脚本部署到Linux服务器,基本没有失手过。中文乱码这个事不复杂,但涉及的环节不少——系统字体、Matplotlib扫描机制、字体名匹配、缓存策略、负号处理——每个环节都可能卡一下。把这些环节都理清楚之后,它就再也不是问题。最实用的经验是:永远用"指定路径注册 + 候选字体列表 + 关闭unicode负号"这三件套,几乎能挡掉所有后续的坑。