在 Python 生态里混了一阵子之后,我发现自己其实一直把一个最常见的文件类型当“黑盒”在用——.ipynb。每天在 Jupyter 里写代码、跑实验、画图、保存,却从来没认真想过这个文件内部到底长什么样。直到有几次分支合并时被一串串不可读的 diff 整得焦头烂额,才开始静下心来把这个格式彻底拆了一遍。这篇文章就把我对.ipynb文件的认识从头捋一遍,包括它的结构、它在工程协作里为什么这么闹心、日常使用效率极高的几个操作方式,以及文件损坏和体积膨胀时的处理思路。如果你也写过 notebook、被 Git 冲突折磨过、或者只是好奇这文件里到底存了什么,那这篇值得看完。
1. 先搞清楚 .ipynb 到底是个什么:JSON 皮囊 + 交互式内脏
很多人以为.ipynb是某种专属二进制格式,其实完全不是。它就是一个标准的 JSON 文件,只是文件后缀换成了.ipynb,全称是 iPython Notebook,但现在基本泛指 Jupyter Notebook。
这意味着你用任何文本编辑器打开它,都能直接看到里面的文字内容,没有任何加密和混淆。也正因为它基于 JSON,Python、R、Julia 甚至 JavaScript 都有现成的解析库,跨语言交换非常方便。一个最简单不过的 notebook 文件,结构大概是这样的:
{ "cells": [ { "cell_type": "code", "execution_count": 1, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": "hello world\n" } ], "source": [ "print('hello world')" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "name": "python", "version": "3.11.0" } }, "nbformat": 4, "nbformat_minor": 4 }粗看这个结构,顶层就是三块:cells、metadata、nbformat。其中nbformat是格式规范的版本号,目前主流是 4,不同小版本之间有一些字段上的兼容差异。metadata是文件的元信息,保存了内核种类、Python 版本、语言信息等。而cells才是真正承载内容的骨架,是一个数组,里面每一元素就是一个单元。
1.1 code cell 里的四件套:source、execution_count、outputs、metadata
在.ipynb文件里,最关键的还是 code cell。每个 code cell 都有四个主要字段:
source:单元格里的源码,一般以字符串数组的形式存在,每个数组元素对应一行,方便解析器的逐行处理。如果你用程序读取,常常需要''.join(cell['source'])才能拿到完整源码。execution_count:这个 cell 最近一次执行的编号。注意是“最近一次执行”,不是执行次数。只要你在 notebook 里按一次运行,它就会变一次。这个字段是 Git 冲突的常客。outputs:执行之后的结果列表。包括纯文本输出、图片 base64、富文本 HTML 等等。典型类型有stream、execute_result、display_data、error等。这个也是导致文件体积膨胀和版本冲突的大头。metadata:记录当前 cell 的局部元信息,比如是否折叠代码、标签等,大多数场景下是空对象。
不同 cell 的metadata内容不一定一样,比如 markdown cell 可以带tags字段,code cell 还可以带collapsed这类 UI 状态。保存这些东西对于用户复现交互状态很有帮助,但对于只有代码本身有意义的项目来说,又显得多余。
1.2 markdown cell 与 raw cell 的区别和应用
除了 code cell,文件里还经常会看到markdown和raw两种类型的 cell。
markdown cell 的source里存的是带#、-等排版的 Markdown 文本,用于写说明、标题、公式。它没有outputs和execution_count。很多人在做技术分享时喜欢把README内容直接写成 notebook 的 markdown cell,好处是代码和文档共存于一个文件,评审的时候可以直接看到上下文。
raw cell 则更特殊一点,它里面的内容不会被执行也不会被渲染,只用于存放你需要“原样保留”的文本,比如 LaTeX 源码片段或者某些特定格式的开头。平时我们很少主动创建 raw cell,但接触nbconvert这类工具时会遇到它。
从本质上讲,.ipynb保存的不仅仅是代码,还保存了“执行后”的整个世界。source是输入,outputs是输出,metadata是环境线索,整份文件就是一个可以再次打开的完整会话记录。这也是它和纯.py文件最大的差别:.py只有逻辑,.ipynb还有故事。
2. 为什么 .ipynb 在 Git 里会变成“冲突之王”:输出、计数器与元数据的锅
如果你和我一样,曾经在一个稍微上点规模的项目里把.ipynb提交进了 Git,大概率会经历过这种场景:两个人各自运行过同一个 notebook,然后各自提交各自的分支,等到 merge 的时候,Git 会直接宣告这个文件冲突,而且 diff 里夹杂着一大堆你根本不想看的 base64 图片数据、执行计数和元数据变化。
问题不在于 Git,而在于.ipynb的默认保存策略太“重”。我们来看几个最容易导致冲突的因素:
execution_count每次执行都会递增,哪怕代码一行没改,只要跑过一次,文件就变了。outputs里保存了大量输出结果,图片转成 base64 后字符串长度吓人,任何二次执行都会让输出对象变化。metadata中的language_info版本号、kernel 路径等都可能因为环境不同而变化。- 有些 UI 操作(比如折叠某个 cell)也会写进
metadata,导致无意义的 diff。
我用一个真实场景说明。你新建一个 notebook,写三行代码,执行完保存,提交到 Git。同事拉下来也执行一遍,保存,提交。此时同一个 cell 的execution_count从1变成2,outputs里的文本前面多了个Out[2]标记,metadata["language_info"]["version"]也换了。Git 拿到两个版本,它不知道你到底改了源码还是只是重新跑了一下,只能把所有变化都当作真实修改来处理。结果就是一个友好 diff 都没有,全屏大红大绿。
2.1 把源头掐掉:提交前清除输出的几种方案
既然冲突主要来自outputs和execution_count,最直接的办法就是让它们不出现在版本控制里。最原始的操作是在 Jupyter 里手动选择 Kernel -> Restart & Clear Output,然后保存。但手动操作的问题在于容易忘,而且多人协作时无法保证每个人都遵守。
更保险的方法是使用nbconvert的命令行能力,在提交前统一处理:
jupyter nbconvert --to notebook --ClearOutputPreprocessor.enabled=True --inplace your_notebook.ipynb这条命令会在原文件上清除所有输出,同时保留源码和 markdown。如果你希望顺便把execution_count也重置掉,再加上一行--ClearMetadataPreprocessor.enabled=True也能做到。把这条命令写进项目的Makefile或者 CI 的脚本里,每次合并前统一跑一遍,冲突能少掉大半。
还有一种思路是让 Git 在提交的时候自动“清洗”文件和真实入库的版本。用一个叫nbstripout的命令行工具,它可以注册一个 Git filter 到当前仓库:本地提交时自动过滤掉输出,但工作区里的文件仍然是带输出的,互不干扰。安装和使用都很简单:
pip install nbstripout然后进入你的 git 仓库执行:
nbstripout --install它会自动修改.git/config,给*.ipynb配上clean和smudge过滤器。之后再提交 notebook,入库的内容就不会包含 output。但前提是团队所有人都要在本地做一次这个操作,否则没装的人还是会把输出提交上去。
2.2 如果真要 review notebook,别用普通 diff,用同类工具
就算你清掉了所有输出,代码层面的 diff 依然可能因为大段的 markdown 和分散的metadata而显得难读。拿普通文本 diff 去看一个 notebook 的 JSON,等于让一个眼科医生用放大镜看一整首诗词拼成的字谜,极不直观。你真正想要的是按 cell 的维度来看变化:哪个 cell 的代码改了,哪个 cell 是新增的,哪一行 markdown 文字被删掉了。
有没有专门做这件事的工具?有。Jupyter 生态里有一个叫 nbdime 的对比工具,可以按 cell 为单位展示两个 notebook 的差异,还能在 JupyterLab 里以 web 界面呈现。命令行来看,两个核心命令就够了:
nbdiff --insecure notebooks/old.ipynb notebooks/new.ipynb nbdiff-web notebooks/old.ipynb notebooks/new.ipynbnbdiff输出的是文本对比,nbdiff-web会打开浏览器,在你眼前并排显示两个 notebook 的差异块,支持一键 merge。用它来做 code review,比在 GitHub 上直接看原始 JSON diff 要好用太多。
我个人的经验是:如果你在一个团队里做数据分析,最好把“提交前清空输出”和“用 nbdime 做 review”这两件事都固定成流程。前者让文件变小变干净,后者让变化点变得可读。这两步在线索清晰度上的提升,远超你想象。
3. 十行最实活的 .ipynb 日常技巧:转脚本、命令行运行、批量操作
聊完了底层结构和协作痛点,这一部分说点日常真正用得上、能让你效率提高不少的操作。很多人只知道双击打开 Jupyter,然后在网页里点点点;实际上.ipynb文件在命令行里也能玩出花样。
3.1 把 notebook 一键变成纯 Python 脚本
有时候你辛辛苦苦在 notebook 里调好了一个分析流程,最后要放进生产环境,或者给别人跑一个干净的.py文件。使用nbconvert的--to script参数可以快速转换:
jupyter nbconvert --to script your_nb.ipynb你会在同目录下得到一个your_nb.py。这个脚本保留了每个 cell 里的代码,并自动加上# In[1]:这样的注释标记,表明每个代码块原属于哪个 notebook cell。如果 notebook 里有 markdown cell,它们会被转换成#注释,不会影响脚本运行。
但是注意,转换出来的脚本是纯顺序执行的,notebook 里那种“先跑 cell 1,再回过去改 cell 2”的交互状态不会被保留。如果代码里存在跨 cell 的变量依赖,脚本执行顺序仍然是从上到下,只要你的 notebook 是干净顺序运行的,一般没问题。
如果你想顺便执行转换后的脚本并验证结果,可以在nbconvert命令后面加--execute。这样它会把整个 notebook 从头到尾执行一遍,把输出记录下来。适合用来检测代码在“全新内核”下是否能完整跑通,某种程度上就是自动化回归测试。
3.2 把 notebook 变成 HTML、Markdown 或 PDF,分享给不装 Jupyter 的人
团队里不是每个人都装了 Jupyter、熟悉 Python。把 notebook 转成静态格式比让他们自己搭环境要划算得多:
jupyter nbconvert --to html your_nb.ipynb jupyter nbconvert --to markdown your_nb.ipynb转换成 HTML 后,你可以直接在浏览器里打开,代码、输出、Markdown 渲染效果都在。转换成 Markdown 后,你还能继续用其他 Markdown 工具加工、发布到博客平台。如果你的机器上装了 LaTeX,甚至可以用--to pdf直接生成 PDF 报告。不过 PDF 转换对中文支持稍麻烦,一般需要额外配置字体,我的经验是不如在浏览器里打印 HTML 来得省事。
还有一个容易被忽略的场景:用nbconvert做批量转换,在自动化报告、定时邮件通知里特别有用。比如每天早上跑一个数据更新脚本,然后把结果 notebook 转成 HTML 发给团队,就是一条命令的事:
jupyter nbconvert --to html --execute report.ipynb --output report.html--execute让 notebook 在执行完所有 cell 之后再导出 HTML,输出的就是带最新数据的报告,比截图和文件搬运优雅得多。
3.3 不打开 Jupyter,直接在命令行里执行 notebook
Jupyter 现在已经不是单纯一个网页 IDE,它提供了 CLI 入口。以 Jupyter 7 为例,你可以这样做:
jupyter execute notebook.ipynb它会在不打开浏览器的情况下,用当前默认内核执行整个 notebook。如果某个 cell 抛出异常,执行会停止并返回非零退出码;你也可以配上--inplace让执行结果直接写回原文件。在 CI 里想跑一个 notebook 回归测试,这一条命令足够。
另外,如果你需要对一批 notebook 做统一操作,比如清理输出、统一转脚本,别手动一个一个跑命令,写个几十行的 Python 脚本一次性处理是更高效的做法。我经常会用下面这段代码遍历目录,把每个 notebook 里的outputs清空并重新写回:
import json from pathlib import Path for ipynb_path in Path(".").rglob("*.ipynb"): nb = json.loads(ipynb_path.read_text(encoding="utf-8")) for cell in nb["cells"]: if cell["cell_type"] == "code": cell["outputs"] = [] cell["execution_count"] = None ipynb_path.write_text( json.dumps(nb, indent=1, ensure_ascii=False) + "\n", encoding="utf-8" )脚本执行速度极快,不会因为你双击打开过 Jupyter 而产生额外状态。配合 Cron 或者定时任务,可以做成每个项目内部的“提交前清洗”步骤。
4. 从一块损坏的 .ipynb 说起:JSON 报错、校验与修复实操
用.ipynb用久了,几乎每个人都会遇到一次打不开文件的情况。最常见的是 Jupyter 启动后点击某个 notebook,网页上直接跳出一行刺眼的红字:“Unreadable Notebook: Notebook JSON is invalid”。
这类错误之所以发生,本质上吃透了.ipynb的 JSON 属性。任何能破坏 JSON 结构的操作都会让文件失效,比如:在笔记本保存的过程中强行断电、磁盘空间写满导致文件截断、某些同步盘把文件部分同步上传、用未保存好就关掉的编辑器覆盖了文件等等。另外冷启动 Jupyter 时如果正好在自动检查点写入期间崩溃,也容易出现这种问题。
4.1 快速定位 JSON 的第几行出了什么错
JSON 的语法错误通常带有具体行号和列号,所以第一步先用标准工具校验一下:
python -m json.tool broken.ipynb如果你足够幸运,它会在终端里明确指出:
Expecting property name enclosed in double quotes: line 62 column 5 (char 1234)这个报错通常意味着某行的 key 或者 value 没有闭合引号,或者在数组末尾多了个逗号。带着这个线索打开原始文本文件,在指定位置前后检查缺失符号,一般能找到问题。
如果错误不是那么明显,你还可以用json.loads在一个 Python 脚本里尝试解析,然后用except json.JSONDecodeError as e拿到e.lineno和e.colno。这比肉眼在一大堆 JSON 里扫要快得多。
另外别忘了 Jupyter 自己的自动检查点文件。在你编辑 notebook 期间,Jupyter 会定期生成文件名-checkpoint.ipynb,通常隐藏在与原文件同级的.ipynb_checkpoints目录下。这个文件是最接近崩溃前状态的备份。我的建议是:遇到损坏,先去.ipynb_checkpoints目录里找找xxx-checkpoint.ipynb。如果它可用,直接用nbformat.read读取再写回一个新名字就行。
4.2 常见 JSON 异常形态和手工修复套路
在我拆过的坏文件里,大概出现得最多的是下面这几类:
| 现象 | 常见原因 | 快速处理 |
|---|---|---|
Unterminated string | 文件被截断,字符串没有结束引号 | 找备份或者自动检查点恢复 |
行尾多了一个逗号,比如} , } | 手动编辑过 JSON,或某种同步工具合并出错 | 删掉多余的逗号 |
Non-ASCII characters乱码 | 文件编码被破坏,UTF-8 内容被转成其他编码 | 尝试用encoding="utf-8"读取,必要时忽略错误 |
数组括号不闭合,]缺失 | 同步或截断导致括号丢失 | 在文件末尾手动补上缺失的]和} |
NaN/Infinity出现在 JSON 里 | 某些旧工具写入非法数字 | 将它们替换成合法 JSON 数字或null |
这里给你一个稍微暴力一点的修复示例。假设你的文件缺少了最后一个大括号,报错是Expecting ',' delimiter: line 800 column 1。你可以先用文本文件读取全部内容,然后在末尾强制补上缺失的}:
from pathlib import Path import json path = Path("broken.ipynb") text = path.read_text(encoding="utf-8", errors="ignore") # 简单粗暴,先清理可能存在的非法末尾逗号 text = text.rstrip() if text.endswith(","): text = text[:-1] # 补上最外层的闭合大括号(只用于确定缺一个)需要注意实际情况 text += "\n}" path.write_text(text, encoding="utf-8")这当然不是一个万能恢复器,但它大概率能救回大部分只是在写文件时掉了最后几个字符的 notebook。真正被底层截断到中间位置的文件,想完整复原几乎不可能,这时候最靠谱的仍然是最初的-checkpoint.ipynb。
4.3 用 nbformat 把合法的 JSON 修复成规范 notebook
还有一种情况:文件本身是合法 JSON,但内部结构不符合 nbformat 规范,比如缺少nbformat字段、cells里出现了未知类型,或者outputs字段结构错乱。这种时刻nbformat.read能帮你诊断:
import nbformat with open("notebook.ipynb", "r", encoding="utf-8") as f: nb = nbformat.read(f, as_version=4)如果它抛出了NotebookValidationError,意味着 notebook 的结构存在硬伤。部分字段缺失时你还能手动重建;如果缺失严重,唯一合适的思路就是从历史提交或同事手里找一份版本。
再提供一个更通用的保险策略:每次打开一个 notebook 时,都检查一下是否包含nbformat和cells字段,没有就立刻报错提醒,不要带病运行。这能避免你拿着一个结构缺失的文件跑一整天,最后才发现数据全没保存。
5. 定位 .ipynb 的“隐藏体积”:为什么一个代码文件能有几十MB,以及如何瘦身
我见过一个 200 行的代码 notebook,体积却超过了 80MB。打开文件,80% 的内容都是一大段 base64 字符串。如果不去看文件的原始内容,你很难想象代码文件也能胖成这样子。这里面的大头基本是图片输出:用matplotlib画个图,Jupyter 默认会把渲染之后的 PNG 甚至 SVG 以 base64 编码直接嵌进 notebook 的outputs里。一张像素高、DPI 高的图,编码后字符串轻松达到几百 KB 甚至数 MB。如果你一年 365 天都在 notebook 里跑同一个小图,你就会得到一个体积爆炸的“时间胶囊”。
其实不仅是图片,文本输出也可能是巨大无比的。比如你在一个 cell 里print(df.head()),结果 DataFrame 有几百列,每列的字符串都很长,Jupyter 会用 HTML 表格的形态把整个结果塞进输出里,体积也会很可观。
5.1 输出里的图片、文本和 HTML 是怎么塞进去的
用一个简单方式可以看到输出对象的原始结构。比如你画了一个图,然后打开.ipynb文件搜索data:image/png;base64,你会看到类似这样的字段:
{ "data": { "image/png": "iVBORw0KGgoAAAANSUhEUg...", "text/plain": "<Figure size 640x480 with 1 Axes>" }, "metadata": {}, "output_type": "display_data" }Jupyter 后端为了让你在网页界面上看到图形,会把图片编码成 Data URL。这个东西对你和你的团队肉眼阅读毫无意义,占空间却是实打实的。
文本输出则长成另一个模样:
{ "name": "stdout", "output_type": "stream", "text": "Running step 1\nRunning step 2\n..." }如果你在循环里重复打印了 10 万行日志,这个text数组就可能膨胀得非常夸张。这类输出在渲染时是合理的,但一旦写进 JSON 文件里,就成了体积负担。
5.2 三种实用的瘦身策略:不存、外置、或者只存源码
第一种策略最简单:提交到版本库之前清空所有输出。这等于删掉了所有outputs字段,文件重归苗条。此前的第 2 章已经提供了方式,就不再重复。但是要注意,如果你需要在本地随时看到输出,又在提交时只提交源码,那么“清空输出”这件事一定要发生在提交前的 filter 流程里,不能直接在工作区手动乱删。
第二种策略是把大图输出到文件,而不是直接内嵌在 notebook 里。拿 matplotlib 举例,不推荐把大图渲染成 base64 放进 notebook 里,而是把图片保存成磁盘文件,然后在 notebook 里用 Markdown 或者IPython.display.Image来引用。这样 notebook 本身只保存一小段相对路径,图片作为独立资源文件存在,体积小、也方便其他工具复用。
import matplotlib.pyplot as plt import IPython.display as display fig, ax = plt.subplots(figsize=(8, 6)) ax.plot([1, 2, 3], [4, 5, 6]) fig.savefig("result.png", dpi=80, bbox_inches="tight") plt.close(fig) # 防止 notebook 自动内嵌输出 display.Image(filename="result.png")这里面的关键点是我把dpi从默认的 100 降到了 80,同时关闭了plt.show(),让图形不会进入 inline 输出,再手动存到文件。这样你的 notebook 体积不会因为一两个图就被撑爆。
第三种策略是在项目目录规范中约定:notebook 只放业务代码和可复用片段,最终数据结果放入 JSON 或 CSV 目录,图片放入 assets 目录。大家的.ipynb变得轻量,审查代码时也更容易聚焦。这个策略,更接近一个工程规范问题,但它带来的可维护性提升是很直接的。
5.3 体积已经爆炸了怎么办:批量压缩历史文件
如果你之前已经有一批体积庞大的.ipynb提交在了仓库里,可以先写一个脚本批量清洗。直接读取 JSON,把所有outputs清空并重写文件:
import json from pathlib import Path from copy import deepcopy def strip_outputs(nb): new_nb = deepcopy(nb) for cell in new_nb.get("cells", []): if cell.get("cell_type") == "code": cell["outputs"] = [] cell["execution_count"] = None return new_nb for p in Path(".").rglob("*.ipynb"): with open(p, "r", encoding="utf-8") as f: nb = json.load(f) with open(p, "w", encoding="utf-8") as f: json.dump(strip_outputs(nb), f, indent=1, ensure_ascii=False)注意:这个脚本会直接改写原文件。跑之前一定先备份,否则你丢掉所有历史输出,再想找就没地方找了。如果要让 git 历史里的旧版本也都瘦下来,那就要涉及改写历史提交,操作更复杂,一般也不建议随意做。更稳妥的办法是:从今天开始让所有 notebook 保持小体积入库,旧的超大文件在必要时用git filter-repo这类工具单独处理,而不是写进日常流程。
6. 从“看到”到“管理”:给 .ipynb 的文件治理几条务实建议
到这里,.ipynb的内部结构、协作痛点和批量操作都梳理清楚了。剩下我想聊一点自己踩过坑之后的工程体会。
第一,对待 notbook 的定位要想清楚:它到底是“报告草稿”还是“生产代码”?如果是前者,保留 outputs 是合理的,因为你想让别人直接看到结果;如果是后者,那它的核心资产应该是源码和 markdown,outputs 只是过程中级产物,直接丢弃才是最佳选择。在我见过的大部分失败项目里,最典型的错误就是把两端混在一起,既想让 notebook 成为日常分析报告,又想把它当成稳定的数据管道。结果就是文件里堆满了执行结果、图片和临时状态,谁都不敢乱动。
第二,代码还是值得抽模块。如果 notebook 里有很长一段数据处理逻辑,而且这段逻辑以后还要复用,那就该趁早把它挪进.py模块,notebook 只负责调用和展示。这样做之后,单元测试可以针对.py文件写,notebook 的变化就只剩调用方式和讨论内容,无论是 review 还是排查问题都会轻松不少。
第三,团队协作里固定一个“提交前动作”非常有价值。我的做法是,在项目根目录放一个Makefile,里面放一条clean-notebooks命令,统一执行清空输出、重置执行计数、格式化 JSON。以后任何人想把代码合进主干,先跑一下这条命令。就算最初忘了几次,养成习惯之后就不会再出现“一个 pull request 里有 40MB 图片输出”的灾难。
最后说一个很实在的小技巧:如果你在 JupyterLab 里打开一个小型 notebook 卡到不行,十次有九次是因为某一个 cell 的输出特别大。快捷键Ctrl+Shift+P打开命令面板,输入 “Clear Outputs”,先把所有 cell 的输出全部清掉,再重新执行你关心的那一段,界面会立刻流畅很多。这个操作我没有在任何教程文档里见过特别强调,但实测下来,它在处理几百 MB 的 notebook 时比提高硬件配置还要管用。
.ipynb本身没有自己生病,它只是一次次帮你保存了代码、结果和整个上下文。理解了这一点,你就明白:它不是天生讨厌做版本管理,而是你需要有意识地控制它往文件里塞东西的欲望。该清的时候清干净,该留的时候留得明白。用好了,它依然是 Python 数据分析工作流里最好用的草稿纸和汇报板。