把 Notion 表格导出成 CSV,这句话看起来只是个格式转换的小需求,但真正动手的时候,你会发现 Notion 的导出逻辑和普通表格软件完全不在一个频道上。我之前帮某业务系统做历史数据迁移,对方只要一张 CSV,正好我手上是在 Notion 里维护了一年的项目台账,结果找导出按钮就找了十几分钟,最后发现官方根本没有“某个数据库 → 一键导出 CSV”这种菜单。折腾了一下午才把数据完整搬出来,中间踩了日期时区、中文乱码、字段丢失一堆坑。
这篇文章就把 Notion 表格到 CSV 的所有可行路径拆开讲清楚,包括官方导出、复制粘贴、第三方网页工具、API 脚本,以及我最后推荐的自动化方案。适合想把 Notion 数据导到 Excel、处理数据、做分析的运营、数据分析师和开发同学,也适合只是偶尔需要导出一张表给同事的普通用户。
1. 先说结论:官方导出并不是“一个表格一个按钮”
1.1 当你按下 Export 时,你拿到的是什么
Notion 的导出入口在左侧 Settings → Export,格式选项是 HTML、Markdown & CSV 和 PDF。很多人一看有 CSV 三个字母就选了 Markdown & CSV,结果下载回来一个压缩包,里面是整个工作区的全部内容,根本不是你想要的那一张表。
我实测拿到的压缩包里大致是这个结构:
export.zip ├── assets/ # 图片、附件等资源文件 ├── pages/ # 所有普通页面的 Markdown 或 HTML └── databases/ # 数据库导出的 CSV 文件也就是说,你得先下载整个工作区,再从里面翻出对应数据库的 CSV 文件。如果工作区里数据库多,还要逐个确认文件名对得上。更麻烦的是,这个 CSV 的质量并不干净:relation 关系字段导出来是一串内部 ID,URL 字段可能被处理成短链接形式,数据库行的正文内容不会合并进 CSV,而是单独拆成一个个 Markdown 文件放在 pages 目录下,和 CSV 行很难一一对上。
所以官方导出更适合做“整个工作区的备份归档”,不适合“我要一张能直接拿去做分析的干净表格”。
1.2 Notion 的数据模型,天生和 CSV 不对付
为什么 Notion 不做“单个数据库导出 CSV”这种常规功能?因为它内部根本不是二维表。
Notion 数据库里的每一行,本质是一个 Page(页面),每一列是一个 Property(属性)。属性只是挂在这个页面上的一组元数据,页面本身还可以包含正文、子页面、附件、评论,这些内容在数据库视图里默认是不显示的,但它们又是页面数据的一部分。
用生活化的类比来理解:Excel 是一张照片,所有内容平铺在一个格子里;Notion 数据库更像是一个文件柜,每个文件夹外侧贴着几张标签(属性),文件夹里面还有一堆文件(正文、子页面)。导出成 CSV,相当于只把外侧标签抄成一张表,里面文件的内容怎么处理?官方选择是拆开,另存成独立的 Markdown 文件。
正是这种结构差异,导致官方导出的 CSV 里经常出现“看起来缺了关键内容”的情况。比如你在 Notion 视图里能看到每个任务有“负责人”“截止日期”“关联客户”,但导出后才发现,负责人变成了内部用户 ID,关联客户的 relation 字段是一串 UUID,根本没法直接给别人用。
2. 四条可行路线横评:从复制粘贴到 API 脚本
不同场景下,需要的导出方案完全不一样。我按自己实际用过的顺序整理了一张对比表。
| 方案 | 原理 | 优点 | 明显缺点 | 适合场景 |
|---|---|---|---|---|
| 官方全量导出 | 下载整个工作区 ZIP | 数据完整,适合备份 | 多级目录,CSV 质量差,关系字段丢失 | 工作区整体备份归档 |
| 复制粘贴 | 浏览器选中表格区域,粘贴到 Excel | 十秒搞定,无需任何配置 | 只有当前视图,隐藏列和折叠分组会错乱 | 几百行以内的小表 |
| 网页导出工具 | 第三方站点授权后输出 CSV | 速度快,保留部分视图逻辑 | 数据要过第三方服务器,有隐私风险 | 一次性使用、非敏感数据 |
| Notion API 脚本 | 直接走官方接口拉数据 | 可控、可自动化、字段类型清晰 | 需要一点代码能力,需配置权限 | 定期同步、数据量大、生产环境 |
我自己试过所有方案之后,长期留下来的其实是复制粘贴和 API 脚本两种。复制粘贴处理小表格是真快,API 脚本负责一切需要自动化、可复现、不能丢数据的场景。下面分别说清楚。
2.1 复制粘贴:最省事,但别在大表上冒险
操作上很简单:在 Notion 表格视图里,把鼠标放到表格左上角,按住往下拖选中所有行,然后 Ctrl+C,再到 Excel 里 Ctrl+V,最后另存为 CSV。
这个方案有几个限制要先讲明白。
第一,复制的数据完全取决于当前屏幕里的视图状态。如果当前视图做了筛选,那你复制出来的就是筛选后的结果;如果有些列被手工隐藏了,复制出来的表格里那些列会直接消失;如果按某个字段做了分组折叠,粘贴到 Excel 后会多出一堆分组占位行,整个表格错乱到没法看。
第二,几百行数据问题不大,几千行以上浏览器会很卡,复制的时候可能会出现“页面无响应”。我试过一次性复制一个 5000 行的表格,Notion 直接卡了快半分钟才弹出复制成功提示,粘贴后还发现有几行被截断了。那次之后就再也没用复制粘贴处理大表。
第三,Notion 里多行文本、换行符会保留在单元格里,粘贴到 Excel 后虽然显示正常,但你要是直接“另存为 CSV”,Excel 可能会把单元格里的换行处理成 CSV 换行,导致行数变多。这个坑后面单独展开。
2.2 网页导出工具:快是真的快,但要谨慎授权
社区里有不少网页版的 Notion 导出器,原理基本是在浏览器里拿到 Notion 的授权 token,然后模拟前端操作把数据库数据拉下来,输出 CSV。用过一次,体验确实流畅,视图里的排序和筛选也能保留大部分,特别适合不会写代码的人。
我之所以没有把它作为长期方案,主要是因为授权之后你的数据会经过第三方的服务器,之后理论上对方可以用你的 token 继续读取数据。如果只是导出一张公开活动报名表倒无所谓,但涉及客户信息、财务数据、内部项目协作内容,我不会冒这个险。官方 API 方案虽然要写代码,但数据是直接从 Notion 服务器到你自己电脑,中间不经过任何人的服务器。
2.3 为什么最后选了 API 脚本
API 方案真正打动我的点有三个。
一是字段类型不会丢。select、multi_select、date、formula、relation 这些在官方导出或复制粘贴里都会被打平、转字符串或直接丢失,但 API 返回的是结构化 JSON,每个属性的类型清清楚楚,可以针对每种类型做精准的转换规则。
二是可以自动化。脚本写好之后,以后每个月把数据库同步成 CSV,一条命令就完成,不需要打开浏览器,也不用担心复制粘贴时手抖。
三是数据安全可控。整个流程只和 Notion 官方 API 通信,不经过任何第三方,token 只保存在本地环境变量里。后面我给的脚本可以直接拿去复用。
3. 实战:用 Notion API 把数据库拉成本地 CSV
3.1 准备工作:创建集成、授权页面、拿 ID
第一步是去 Notion 的集成管理页面创建一个 Internal Integration。创建时只需要填一个名字,选关联的 workspace,然后创建完会得到一个 Secret Token,格式是secret_xxxxxx,这个 token 相当于这个集成的钥匙,只用一个数据库的话一把钥匙够了。
然后要授权页面。打开你想导出的那个数据库页面,点击右上角的...菜单,找到Connections,在弹出的面板里搜索你刚才创建的集成名称,点确认。这一步不做的话,后面调 API 必然返回 403,很多人第一次都栽在这。
最后拿 database_id。打开数据库页面,看浏览器地址栏,URL 结构一般是这样的:
https://www.notion.so/workspace_name/0123456789abcdef0123456789abcdef?v=abcdef...中间那串 32 位的十六进制字符串才是 database_id,?v=后面那一串是视图 ID,别拿错了。如果你打开的是数据库页面但 URL 里没有明显 ID,可以用Ctrl+L复制完整链接再解析。
配置好之后,先用一条curl验证一下权限和 ID 是否正确:
curl -X POST "https://api.notion.com/v1/databases/YOUR_DATABASE_ID/query" \ -H "Authorization: Bearer YOUR_SECRET_TOKEN" \ -H "Notion-Version: 2022-06-28" \ -H "Content-Type: application/json"如果返回 JSON 里包含results数组,说明权限和 ID 都没问题。注意Notion-Version这个 Header 是必填的,不同版本返回的数据结构会有差异,我这套代码基于2022-06-28验证过,建议就用这个版本。
3.2 读取数据库所有行:分页循环是核心
Notion API 查询数据库的接口是POST /v1/databases/{id}/query,一次最多返回 100 行。如果你的数据超过 100 行,必须依靠返回体里的has_more和next_cursor做循环翻页。
翻页逻辑不复杂,但很关键:第一次请求不带start_cursor,拿到响应后如果has_more为 True,就把next_cursor塞进下一次请求的 body,直到has_more为 False。漏掉这个步骤,导出的 CSV 会只有前 100 行,而且不容易被发现。
def fetch_all_rows(database_id, token): url = f"https://api.notion.com/v1/databases/{database_id}/query" headers = { "Authorization": f"Bearer {token}", "Notion-Version": "2022-06-28", "Content-Type": "application/json", } all_rows = [] start_cursor = None while True: payload = {"page_size": 100} if start_cursor: payload["start_cursor"] = start_cursor resp = requests.post(url, headers=headers, json=payload) resp.raise_for_status() data = resp.json() all_rows.extend(data.get("results", [])) if not data.get("has_more"): break start_cursor = data.get("next_cursor") return all_rows注意start_cursor更新要放在has_more判断之后,否则最后一次循环会把 None 塞进去导致多余请求。这个案例我写成代码时是清醒的,但实际调试时还真因为这个多打了一次无意义请求。
3.3 属性值提取:每种字段类型都有坑
这是整套脚本里最容易出错的部分。Notion API 返回的properties是一个 dict,其中每个属性的 value 结构完全取决于这个属性的类型。拿到的属性可能是这个样子:
{ "姓名": { "id": "title", "type": "title", "title": [ { "plain_text": "张三" } ] }, "状态": { "id": "select", "type": "select", "select": { "name": "已完成" } }, "标签": { "id": "multi_select", "type": "multi_select", "multi_select": [ { "name": "紧急" } ] }, "截止日期": { "id": "date", "type": "date", "date": { "start": "2025-01-10" } } }如果直接粗暴地把这个 JSON 塞进 CSV,那导出来的文件没办法看。需要针对每种类型单独写提取逻辑。我这里直接给一个可直接运行的提取函数,覆盖了主要类型:
def convert_rich_text(items): if not items: return "" return "".join(item.get("plain_text", "") for item in items) def extract_property_value(prop): prop_type = prop.get("type") if prop_type in ("title", "rich_text"): return convert_rich_text(prop.get(prop_type, [])) if prop_type == "number": value = prop.get("number") return "" if value is None else value if prop_type in ("select", "status"): value = prop.get(prop_type) return value.get("name", "") if value else "" if prop_type == "multi_select": values = prop.get("multi_select", []) return ";".join(item.get("name", "") for item in values) if prop_type == "date": date_obj = prop.get("date") if not date_obj: return "" start = date_obj.get("start") or "" end = date_obj.get("end") or "" return start + (" -> " + end if end else "") if prop_type == "checkbox": return prop.get("checkbox", False) if prop_type == "url": return prop.get("url") or "" if prop_type in ("email", "phone_number"): return prop.get(prop_type) or "" if prop_type == "formula": formula = prop.get("formula", {}) f_type = formula.get("type") if f_type == "string": return formula.get("string") or "" if f_type == "number": return formula.get("number") or "" if f_type == "boolean": return formula.get("boolean") or "" if f_type == "date": return formula.get("date") or "" return "" if prop_type == "relation": related = prop.get("relation", []) return ",".join(item["id"] for item in related) if prop_type == "people": people = prop.get("people", []) return ";".join(p.get("name", "") for p in people) if prop_type == "created_by": return prop["created_by"].get("name", "") if prop_type == "last_edited_by": return prop["last_edited_by"].get("name", "") if prop_type == "created_time": return prop.get("created_time", "") if prop_type == "last_edited_time": return prop.get("last_edited_time", "") if prop_type == "rollup": rollup = prop.get("rollup", {}) r_type = rollup.get("type") if r_type == "number": return rollup.get("number", "") if r_type == "string": return rollup.get("string", "") if r_type == "date": return rollup.get("date", "") if r_type == "array": arr_items = rollup.get("array", []) # 数组里每个元素仍然是一个属性值对象,可以递归处理 return ";".join(str(extract_property_value(item)) for item in arr_items) return "" return ""关于rollup要单独说两句:rollup 是对关联数据库跨表聚合后的结果,API 返回结构非常不稳,尤其当聚合类型是数组时,里面每个元素的类型取决于源属性类型。上面的递归调用能处理大多数情况,但如果你实际跑脚本时发现 rollup 字段导出为空,大概率是遇到了 API 文档中未覆盖的聚合类型。我的处理方法是:不纠结,安全兜底返回空字符串,同时把原始 JSON 打一条日志,后续可以针对特殊类型再补分支。
relation字段我默认导出的是关联页面的 UUID,这是最保险的做法。如果你想导出成“关联页面的标题”,需要额外调用查询接口去拿目标页面的 title,会显著增加请求次数。我的建议是第一版先导成 UUID,如果下游系统真的需要可读文本,再加一个映射表去翻译。
3.4 写出 CSV:列顺序固定,编码用 utf-8-sig
所有行数据提取完成后,要写 CSV 文件。这里有两件事必须处理干净,否则后面一定有同学踩坑。
第一,CSV 的列顺序。Notion API 返回的 properties 字典并不保证列顺序稳定,所以不要用 Python 默认字典序去生成列头。我的做法是手工在脚本里维护一个FIELD_ORDER列表,按数据库视图的顺序写清楚,这样导出的 CSV 列顺序稳定可控,下游加工时也方便。
第二,编码。直接用encoding="utf-8"写 CSV 没问题,但 Excel 打开时大概率乱码。Windows 上的 Excel 默认用 GBK 读取无 BOM 的 UTF-8 文件,所以导出时最好用utf-8-sig,它会自动在文件开头加 BOM,Excel 就能正确识别。
import csv FIELD_ORDER = ["姓名", "状态", "标签", "截止日期", "负责人", "备注"] def write_csv(rows, output_path): with open(output_path, "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=FIELD_ORDER) writer.writeheader() for row in rows: writer.writerow(row)加上前面读取数据的逻辑,一个最小可用的完整脚本大概长这样:
import os import requests import csv NOTION_TOKEN = os.environ.get("NOTION_TOKEN") DATABASE_ID = os.environ.get("DATABASE_ID") FIELD_ORDER = ["姓名", "状态", "标签", "截止日期", "负责人", "备注"] # 这里把 3.2 和 3.3 的函数直接搬过来 # fetch_all_rows(database_id, token) # extract_property_value(prop) def export_database_to_csv(output_path): pages = fetch_all_rows(DATABASE_ID, NOTION_TOKEN) rows = [] for page in pages: props = page.get("properties", {}) row = {} for field in FIELD_ORDER: prop = props.get(field) if prop is None: row[field] = "" else: row[field] = extract_property_value(prop) rows.append(row) write_csv(rows, output_path)注意上面的代码里,FIELD_ORDER中的名称必须和你 Notion 数据库中的列名完全一致,包含空格和符号。如果不一致,导出来会出现整列为空或字段丢失。
为了便于调试,建议把环境变量NOTION_TOKEN和DATABASE_ID放到.env文件或 shell 里,不要硬编码进脚本。这样后续给人演示或者放到 CI 环境跑,也不需要改代码。
4. 实操中那些反复出现的坑:问题排查实录
这套脚本从跑通到稳定,中间我踩了不少坑,而且每一个都很有代表性,逐个记录一下。
4.1 403、404、401 状态码的确认顺序
我在跑脚本时遇到过三个状态码,排查顺序很重要。
403 是最常见的:数据库页面没有共享给集成。去页面右上角...→Connections里把集成加上,基本能解决。注意集成必须在每个数据库页面单独添加一次,即使同属一个 workspace,也不会自动生效。
404 通常是 database_id 错误。最常见的情况是把 URL 里的?v=后面的 view_id 当成了 database_id。database_id 是 URL 中页面标题和 view_id 之间的那 32 位 hex 字符串,用正则提取最稳。
401 表示 token 无效。检查环境变量是否设置正确,或者创建集成后是否重新生成了 secret。如果你把旧的 token 写死在脚本里,重新生成密钥后脚本就会一直 401。
4.2 Excel 打开 CSV 中文乱码
这个坑出现概率极高,但解决起来最简单。在 Notion 侧,属性里的中文导出后是正常的 UTF-8,直接写到文件没问题,但用 Excel 打开 CSV 时,Excel 会按照系统默认编码去猜,Windows 中文系统默认 GBK,于是 UTF-8 的文件会被看成乱码。
处理方式是文件开头加 UTF-8 BOM,Python 里写encoding="utf-8-sig"即可。如果你用其他语言写脚本,记得确认对应的编码参数。另外一个冷知识:macOS 上 Numbers 对无 BOM 的 UTF-8 识别没问题,但 Windows Excel 一定用 BOM 方案处理,通吃平台。
4.3 日期字段时区偏移和格式问题
官方导出 CSV 和 API 导出的日期都可能出现“和 Notion 页面里显示不一致”的情况。因为 Notion API 返回的date是 ISO 8601 UTC 格式。如果你的 Notion 数据库里设置的是东八区时间,API 返回的日期可能比页面显示晚 8 小时。
简单处理方式是把日期字段当成字符串保存,不要让它转成 Excel 原生日期类型。我上面代码里直接返回start -> end的字符串文本,就是为了避免 Excel 自动推断类型。如果确实需要让 Excel 识别为日期,建议显式转换成YYYY-MM-DD HH:mm格式,并统一在脚本里加时区偏移。
4.4 分页循环写错导致数据丢失
分页循环里最容易犯的错是:只取第一页,然后看到has_more为 True 但没有继续循环。另一个错是把next_cursor更新写进if not has_more分支外层,导致最后一次多请求一次。
我之前调试一个 2000 行的库,导出后只核对第一页,以为没问题,后来放到下游统计才发现总数差了几百行。从那以后,我的脚本每次都返回总行数,写完 CSV 后打印一行exported: X rows,用来和 Notion 数据库右下角的总数核对。这一步操作虽然简单,但能省下后面排查数据丢失的大量时间。
4.5 视图里的排序筛选,API 一概不认
API 查询返回的是数据库的全部数据,和当前视图是否做了筛选、排序、分组没有任何关系。即使你在 Notion 里把视图筛选成“仅本周任务”,API 导出后你拿到的仍是所有历史任务。
这一点非常重要。如果需求是“导出当前视图筛选后的结果”,那 API 方案并不直接支持。我的变通做法有两种:一是在 Notion 页面里先调整筛选条件,然后复制粘贴;二是把筛选条件翻译成 API 请求里的筛选参数,写在脚本里。第二种做法适合筛选条件固定、要定期执行的任务,而且能自动化,长期看比复制粘贴靠谱。Notion 查询接口支持filter参数,这部分逻辑可以单独封装,本文先不展开。
4.6 大量数据的请求频率限制
Notion API 对每个集成有速率限制,官方的阈值是每 5 秒最多 3 个请求,超过会返回 429。如果数据库有几万行,按页面 100 行算,大约需要几百次请求,跑下来大概十分钟。我在一个 3 万行的数据库上实测过,不加任何限速时中间会触发一次 429,加一个简单的重试逻辑就很稳定。
重试逻辑不复杂:遇到 429 时 sleep 两秒,然后重试同一请求,最多重试三次。这里不贪多,保证过程不被打断就行。下面的片段可以直接嵌进fetch_all_rows:
for attempt in range(3): resp = requests.post(url, headers=headers, json=payload) if resp.status_code == 429: time.sleep(2) continue resp.raise_for_status() break5. 我的最终选型建议
如果你只是想偶尔取一次数据,复制粘贴最快,但记得先检查当前视图的筛选分组,避免拿到一份“以为全量其实只是视图内”的 CSV。
如果你要定期把 Notion 数据同步到其他系统,我强烈建议花半小时把 API 脚本搭起来,做成一个小工具,每次手动跑或者挂到定时任务里。这套脚本最大的价值不是省掉一次鼠标操作,而是让导出结果可重复、可预期,不会因为同事调整了视图就导出错数据。
从我自己的实践来看,有两点经验是最值钱的:第一,导出文件名务必带上日期,比如tasks_20250108.csv,避免把旧文件覆盖后找不到历史版本;第二,列顺序一定手工固定,不要依赖 API 返回的字典顺序,否则同一个脚本今天导出和明天导出的列顺序可能不同。把这些细节处理好,Notion 导出 CSV 这个场景才能真正做到“一次配置,长期可用”。