所有文章

2026-10-01

· Dylan Yu
SQLiteCSVimporttutorial

CSV 导入 SQLite:把 CSV 数据导入数据库的 4 种方法

把 CSV 导入 SQLite 的四种实用方法——sqlite3 CLI、DB Browser、DBeaver 和 pandas——外加那些会悄悄毁掉你导入的编码、分隔符和类型亲和性陷阱。

你有一个 CSV,想把它放进 SQLite。这本该是两分钟的活儿,通常也确实如此——直到它不再是。

我把 CSV 导入 SQLite 的次数多到数不清,来源包括从 Google Sheets 导出的电子表格、客户旧 MySQL 机器里的数据库转储、被某人改名为 .csv 的日志文件,还有一个令人难忘的文件——它其实是带 BOM 和分号分隔符的 UTF-16。每一个都号称"只是个 CSV"。

有四种靠谱的做法,选哪种取决于两个问题:你会多久做一次,以及 CSV 有多乱。如果是一次性的、文件也干净,sqlite3 CLI 三行就能搞定。如果你要每周从脚本里重新跑一次导入,你会想要 Python。如果文件一团糟、你宁愿点鼠标也不想调试,那么一个 GUI 向导就物有所值了。

简短版如下:

  • sqlite3 CLI —— 对付干净的一次性文件最快。macOS 上无需安装。除非你另有说明,否则一切都是 TEXT。
  • DB Browser for SQLite —— 一个带分隔符、编码和表头选项的 GUI 向导。不错的折中方案。
  • DBeaver —— 当文件真的很棘手,或者你本来就泡在 DBeaver 里时,用它的导入向导。对简单文件来说是大材小用。
  • Python + pandas —— 当导入需要可重复、可脚本化,或作为管道的一部分时的答案。

我会用真实的命令把四种方法都过一遍,然后讲几乎每个教程都会跳过的那部分:究竟哪里会出错,以及数据落地之后怎么修。

导入之前:CSV 究竟哪里会出错

CSV 是一种有规范——RFC 4180——却几乎没人遵守的格式。这是大多数痛苦的根源。以下是我最常碰到的问题。

编码与 UTF-8 BOM

最常见的失败:Windows 上从 Excel 导出的 CSV 经常被保存为带 BOM(字节顺序标记)的 UTF-8,偶尔是 Windows-1252 / Latin-1。BOM 是文件开头三个看不见的字节。如果你的导入工具不去掉它们,你的第一个列头就会变成 \ufeffid 而不是 id,于是所有引用 id 的查询都会以 "no such column" 失败。

如果你在第一个列名开头看到像  或  这样的怪字符,那就是 BOM。在 sqlite3 CLI 里,你通常可以通过预先清理文件来避免这个问题。在 Python 里,只要传入 encoding="utf-8-sig",pandas.read_csv 就能处理它。

非 UTF-8 编码是另一半问题。保存为 Windows-1252 的文件能导入,但带重音的字符会变成乱码(café 而不是 café)。修复方法是按正确的编码解码,而不是手动逐行编辑。

逗号以外的分隔符

尽管叫这个名字,很多"CSV"文件其实是分号分隔的(在欧洲的 Excel 导出里很常见,那里逗号是小数分隔符)、制表符分隔的,或竖线分隔的。sqlite3 CLI 可以用 .separator 处理这些,但如果你没注意到分隔符是分号,你就会得到一整根巨大的列。

包含逗号、引号和换行的带引号字段

这正是幼稚的解析器会崩溃的地方。一个格式良好的 CSV 会给任何包含逗号、引号或换行的字段加引号:

id,name,notes
1,"Smith, John","He said ""hello"" and left"
2,Jane,"Line one
Line two"

sqlite3 的 CSV 导入器和 pandas 都能正确处理这个。你自己脚本里的 split(",") 则不能。如果行数算错了——行数太多——那几乎总是带引号字段里嵌入的换行被幼稚的解析器拆成了两条记录。

不一致的日期格式

一列,三种格式:2026-01-15、01/15/2026 和 15 Jan 2026。SQLite 没有原生日期类型,所以不管什么落地都是以文本落地,然后 ORDER BY date 按字典序排序,给你一堆胡言乱语。定下一种格式(我用 ISO 8601,YYYY-MM-DD),并在导入前或导入后归一化。

前导零

邮编、电话号码、产品 SKU——任何带前导零的东西。如果你的导入把列声明为 INTEGER,00501 就会变成 501。如果你让它以 TEXT 落地,零就能保住。这是一个你必须刻意做出的类型决定。

空列与空行

尾随逗号会产生空的尾部列。空行会产生每个值都为空的记录。两者都不会报错——它们只是悄悄污染你的表。导入前值得快速看一眼文件。

"一切都是 TEXT"的结果

这是最大的一个,而且专属于 SQLite。SQLite 是动态类型的。它不会像 PostgreSQL 或 MySQL 那样强制列类型。相反,每一列有一个类型亲和性——关于值应当如何存储的提示——而每个值的存储类别是逐值决定的。

这在实践中意味着:当 sqlite3 CLI 导入 CSV 时,它会用没有声明类型的列来创建表,除非你先自己建好了表。没有声明类型的列具有 BLOB 亲和性,值会以文本存入。所以你那个装满 19.99 的 price 列是 TEXT,装满 5 的 quantity 列是 TEXT,而 SUM(quantity) 可能不会如你所愿,因为值是以字符串存储的。

你仍然可以对看起来像数字的文本运行 SUM()——SQLite 会强制转换——但你失去了那些保证,比较会变得微妙地古怪,而且任何读取 schema 的工具都完全看不到类型。解决办法是你自己用想要的类型创建表,然后再导入进去。下面我会演示。

这就是整体地形。现在讲四种方法。

方法 1:sqlite3 CLI

对于干净的一次性文件,这是最快的路径,也是我的默认选择。先说安装:sqlite3 随 macOS 一起提供,所以你已经有了。在 Windows 上它不是默认安装的——你从 sqlite.org/download.html 下载命令行工具,把 sqlite3.exe 放到你的 PATH 里。这些点命令的完整参考在 sqlite.org/cli.html。

两行版本

最低限度长这样:

sqlite3 mydb.db
.mode csv
.import people.csv people

就这样。.mode csv 告诉 CLI 把输入和输出当作 CSV,而 .import file table 把文件读进名为 people 的表。

有两个坑,而且都很重要:

  1. 表必须已经存在。 .import 不会创建它。如果 people 不存在,你会得到错误。(有个小插曲:如果表名不存在而你正在导入文件,某些版本会创建它——但创建出来的表列是没有类型的,这就是"一切都是 TEXT"的问题。别依赖它。显式地创建表。)
  2. 列顺序很重要,而且表头行会被当作数据导入。 .import 把每一行都当作数据读取。如果你的 CSV 有表头,除非你跳过它,否则那个表头会成为你的第一行。

处理表头行

现代的 sqlite3 CLI 支持在 .import 上使用 --skip 选项:

.mode csv
.import --skip 1 people.csv people

--skip 1 跳过第一行——正是表头所需的。(如果你用的是没有 --skip 的旧版本,经典的变通做法是把表头当作一行导入,然后 DELETE FROM people WHERE rowid = 1;,或者把文件通过 tail -n +2 管道处理。在可用的情况下,我更喜欢 --skip。)

完整的实操示例

我们好好做一遍,用显式的 schema 让列拥有真正的类型。假设 people.csv 长这样:

id,name,email,signup_date,account_balance
1,Alice,alice@example.com,2026-01-15,120.50
2,Bob,bob@example.com,2026-02-03,0
3,"Smith, Carol",carol@example.com,2026-02-20,42.00

注意带引号的 "Smith, Carol"——那个逗号在引号里面,必须保持为一个字段。这是整个会话:

sqlite3 people.db
-- 1. Create the table with real types BEFORE importing.
CREATE TABLE people (
    id              INTEGER PRIMARY KEY,
    name            TEXT NOT NULL,
    email           TEXT UNIQUE,
    signup_date     TEXT,      -- ISO 8601 date, stored as text
    account_balance REAL
);

-- 2. Switch to CSV mode and import, skipping the header row.
.mode csv
.import --skip 1 people.csv people

-- 3. Verify.
SELECT COUNT(*) FROM people;          -- expect 3
SELECT * FROM people LIMIT 5;

因为我声明了类型,id 是 INTEGER,account_balance 是 REAL,signup_date 是保存 ISO 日期的 TEXT。带引号的名字作为单一值 Smith, Carol 导入。如果我让 CLI 来建表,每一列都会是没有类型的。

如果你的文件用不同的分隔符,在导入前设置它:

.mode csv
.separator ";"
.import --skip 1 people.csv people

关于类型亲和性的一点说明

即使声明了类型,也要记住 SQLite 的亲和性规则是转换,不是关卡。一个声明为 INTEGER 的列,如果你插入 'abc',它照样会以文本存储——SQLite 不会拒绝它。声明类型能让你对格式良好的数据得到正确的存储,并在 SUM()、AVG()、比较和索引中得到正确的行为。它不能给你校验。如果你需要硬校验,你需要 CHECK 约束或应用层检查。

方法 2:DB Browser for SQLite

DB Browser for SQLite 是一个免费、开源的 GUI,它的 CSV 导入向导是这一堆里对一次性文件最友好的。当文件很小、我想在提交前先亲眼看看预览,或者来求助的人对终端不自在时,我会用它。

工作流程:

  1. 打开或创建你的 .db 文件。
  2. 进入 File → Import → Table from CSV file…
  3. 选择你的 CSV。你会立刻得到一个预览,带有以下下拉选项:
    • Field separator —— 逗号、分号、制表符、竖线,或自定义字符串。
    • Quote character —— 通常是 ",但有些导出用 '。
    • Encoding —— UTF-8、UTF-16、Latin-1 等等。你在这里修复 BOM 和乱码问题。
    • First row contains column names —— 勾选它,把表头当作列名而不是数据行。
  4. 在预览网格里编辑列名和类型。这是杀手锏功能:你可以就在这里把 id 设为 INTEGER、balance 设为 REAL、日期设为 TEXT,这样你就不会撞上"一切都是 TEXT"的结果。
  5. 给表命名,点击 OK,它就会一步创建表并导入行。

相对 CLI 的优势在于你能看到即将发生什么——分隔符、编码和解析出的列在任何东西被写入之前都可见。如果一个带逗号的引号字段会出问题,你会在预览里看到列数不对。

代价是它很费点击。每周都通过向导做同样的导入很枯燥,而且你没法把它放进脚本。为此,你需要方法 4。

方法 3:DBeaver

DBeaver 是一个通用数据库客户端——它能连 SQLite、PostgreSQL、MySQL 以及几十种其他数据库。如果你已经在用它管理你的其他数据库,它的 CSV 导入向导意味着你无需安装或学习任何新东西。我的 DBeaver 对比里有它和专门打造的 SQLite 工具相比的完整剖析。

SQLite 的导入流程:

  1. 连接到你的 SQLite 数据库(DBeaver 从一个 .db 文件路径创建连接)。
  2. 右键点击目标表(或数据库,以创建新表)→ Import Data。
  3. 选择 CSV 作为来源,指向你的文件。
  4. 向导会带着你走过分隔符、引号字符、编码和表头行,并带有实时预览。
  5. 把 CSV 列映射到表列,选择如何处理已存在的行(追加、清空等),然后运行。

DBeaver 的向导是三个 GUI 选项里最可配置的。它很好地处理奇怪的编码、自定义分隔符和列映射,而且因为它是一个真正的 SQL 客户端,你可以立即对结果运行 SELECT 查询,无需切换工具。

什么时候是大材小用: 如果你只碰 SQLite,而且只是偶尔碰,DBeaver 是一个庞大、基于 Java 的应用,有很多你永远用不到的表面积。对于单个干净的 CSV,打开 sqlite3 CLI 敲两条命令比走向导更快。如果你真正想要的是针对结果表的查看器/编辑器而不是导入工具,那么更轻量的本地优先工具更合适——这大致就是 BaseVolt 的功能集瞄准的空缺。结尾再细说。

方法 4:Python + pandas

如果导入需要发生不止一次——每周刷新、构建步骤、ETL 任务的一部分——用脚本处理是正确选择。Python 加 pandas 是标准做法,它比 CLI 优雅得多地处理那些棘手的场景(编码、分隔符、日期、dtypes)。

核心是两个调用:read_csv 加载,to_sql 写入。

import sqlite3
import pandas as pd

# 1. Read the CSV. utf-8-sig strips a UTF-8 BOM if present.
df = pd.read_csv(
    "people.csv",
    encoding="utf-8-sig",
    sep=",",                 # change to ";" for semicolon files
    dtype={"id": "Int64"},   # keep id as integer, allow NULLs
    parse_dates=["signup_date"],
)

# 2. Normalize text columns (strip stray whitespace).
df["name"] = df["name"].str.strip()

# 3. Write to SQLite. if_exists="replace" rebuilds the table each run;
#    use "append" to add rows instead.
with sqlite3.connect("people.db") as conn:
    df.to_sql("people", conn, if_exists="replace", index=False)

关于这个模式,有几件事值得了解:

  • if_exists="replace" 每次运行都会删除并重建表——对完整刷新来说很完美,但如果表里有其他数据或被外键引用,就很危险。添加行时用 "append"。如果你需要连接上强制外键,在连接之后立即设置 PRAGMA foreign_keys = ON;;为什么它默认关闭,见我写的外键指南。
  • Dtypes 很重要。 pandas 会推断类型,只要有任何缺失值,它就会乐呵呵地把 id 列变成浮点数,把 1 变成 1.0。显式声明 dtype 可以避免这一点。
  • to_sql 使用表的声明类型,从 DataFrame 的 dtypes 派生而来,所以你得到真正的 INTEGER / REAL / TEXT 列,而不是全是 TEXT 的结果。
  • 对于大文件,给 to_sql 加上 chunksize=10000,让它分批写入,而不是在内存里构建一个巨大的事务。
  • 你也可以分块读取 CSV,如果文件放不进内存,就在 read_csv 上用 chunksize。

这是能扩展的方法:给它做版本控制、放进 CI、按需重跑。向导和 CLI 方法是给人用的;这个方法是给机器用的。

对比

方法最适合处理乱 CSV可重复设置成本
sqlite3 CLI干净的一次性文件;快速导入有限(仅分隔符和表头)通过 shell 脚本macOS 上无需;Windows 上需下载
DB Browser for SQLite你想要可视化预览的一次性文件好(编码、分隔符、引号、类型)否——手动点击安装 GUI(下载很小)
DBeaver乱文件,或者你已经用 DBeaver强(完整映射、编码、分隔符)否——手动向导安装一个庞大的 Java 应用
Python + pandas可重复的管道和定时导入最强(显式编码、dtypes、日期)是——完全可脚本化已安装 Python + pandas

规律是:对干净文件,CLI 赢在速度;对乱的一次性文件,GUI 赢在可见性;pandas 赢在可重复性。没有单一的最佳答案,这就是为什么有四种方法而不是一种。

一个端到端的实操示例

我用一个小的、稍微有点乱的 CSV 把它串起来,让你看到完整的过程——从原始文件到验证过的表。叫它 orders.csv:

order_id,customer,email,order_date,total,notes
1001,Alice,alice@example.com,2026-03-01,49.99,
1002,"Doe, John",john@example.com,2026-03-02,12.50,"Rush order, ship fast"
1003,Alice,alice@example.com,2026-03-05,0,

注意三个真实世界的褶皱:一个带逗号的引号名字("Doe, John")、一个带逗号的引号备注,以及两行上的一个空的尾部字段。幼稚的 split(",") 会把两个引号字段都搞坏。

步骤 1 —— 用类型创建表。

CREATE TABLE orders (
    order_id    INTEGER PRIMARY KEY,
    customer    TEXT NOT NULL,
    email       TEXT,
    order_date  TEXT,       -- ISO 8601
    total       REAL,
    notes       TEXT        -- empty string -> NULL we'll fix later
);

步骤 2 —— 导入。

.mode csv
.import --skip 1 orders.csv orders

步骤 3 —— 验证 schema。 .schema 会打印出 SQLite 所见的 CREATE TABLE 语句,确认你的类型保住了:

.schema orders
-- CREATE TABLE orders (
--     order_id    INTEGER PRIMARY KEY,
--     customer    TEXT NOT NULL,
--     email       TEXT,
--     order_date  TEXT,
--     total       REAL,
--     notes       TEXT
-- );

步骤 4 —— 验证行数。 你导入了一个 3 行的文件(外加一个你跳过的表头),所以你预期是 3:

SELECT COUNT(*) FROM orders;
-- 3

如果你忘了 --skip 1,这里会显示 4,而且你会有一条 order_id = 'order_id' 的行。这一个检查就能抓住最常见的导入错误。

步骤 5 —— 抽查棘手的行。 确认引号字段完整无损地通过了:

SELECT order_id, customer, total, notes FROM orders;
-- 1001 | Alice      | 49.99 | (null)
-- 1002 | Doe, John  | 12.50 | Rush order, ship fast
-- 1003 | Alice      | 0.0   | (null)

Doe, John 和 Rush order, ship fast 各自都是单一值——引号起作用了。而空的尾部字段以 NULL 进来,这就把我们带到清理环节。

导入后修复数据

导入是容易的那一半。以下是我几乎总会接着做的事。

类型:检查你实际得到了什么

如果你没有声明类型(或者用了某个替你创建表的工具),检查一下它们:

SELECT typeof(order_id), typeof(total) FROM orders LIMIT 1;

如果 typeof(total) 返回 'text',你的数字就是字符串。与其重建,你通常可以只是用类型重新创建表并复制——或者,如果你在用 pandas,用显式的 dtypes 重跑导入。

日期

SQLite 没有日期类型。日期以 TEXT(ISO 8601 是理智的选择)或 INTEGER(Unix 纪元)或 REAL(儒略日)存在。无论什么格式,保持一致。如果一列有混合格式,归一化:

-- Rewrite US-style MM/DD/YYYY into ISO YYYY-MM-DD.
UPDATE orders
SET order_date = substr(order_date, 7, 4) || '-' ||
                 substr(order_date, 1, 2) || '-' ||
                 substr(order_date, 4, 2)
WHERE order_date LIKE '__/__/____';

这样 ORDER BY order_date 就能正确排序,因为 ISO 字符串按字典序排序。对于更复杂的情况,在数据进入数据库之前用 pandas 的 parse_dates 归一化——它比在 SQL 里做字符串手术更不容易出错。

NULL 与空字符串

这一点经常咬人。一个带空字段的 CSV 会以空字符串 '' 导入,而不是 NULL。它们是不同的东西:WHERE notes IS NULL 找不到它们,而 COUNT(notes) 会把它们算作存在。

-- Turn empty strings into real NULLs.
UPDATE orders SET notes = NULL WHERE notes = '';

在导入之后立刻做这件事,这样下游查询和你的数据工具才会表现正常。(在 pandas 里,to_sql 之前 df.replace("", pd.NA) 做的是同一件事。)

去重

重跑导入是产生重复行的经典方式。如果你有自然键,用它:

-- Remove duplicate order_id rows, keeping the lowest rowid.
DELETE FROM orders
WHERE rowid NOT IN (
    SELECT MIN(rowid) FROM orders GROUP BY order_id
);

更好:在 schema 层面用自然键上的 UNIQUE 约束防止重复,并在后续导入中使用 INSERT OR IGNORE。

添加主键

如果你的表来自一个没有主键就创建它的工具,而你有一个自然键列,你就和往现有 SQLite 表添加任何约束处于同一条船:ALTER TABLE 不能添加主键。你重建这张表——用主键创建一张新表,复制行,删除旧表,重命名。(如果你有一列富余、只想要一个基于 rowid 的代理键,请注意每张 SQLite 表已经有隐式的 rowid,你可以在查询中使用它而无需声明任何东西。)

完整的重建模式——以及为什么它是一个多步骤过程——见我 SQLite 外键指南里的 ALTER TABLE 部分。表干净之后,如果你想不写更多 SQL 就浏览和编辑它,下一步是在一个合适的查看器里打开 .db 文件;我关于如何打开 .db 文件的指南涵盖了各种选项。

疑难解答

我一次又一次看到的失败,以及它们的含义:

  • "no such table: X" —— .import 要求表存在。先创建它。这是 #1 CLI 错误。
  • 第一列名为 id 或 id —— UTF-8 BOM。重新保存为不带 BOM 的 UTF-8,或在 pandas 中用 encoding="utf-8-sig" 导入。
  • 行数比预期多一 —— 你没有跳过表头。加上 --skip 1(CLI)或勾选 "first row contains column names"(GUI)。
  • 所有东西都落进了一列 —— 分隔符错了。导入前设置 .separator ";"(或正确的那个),或在向导里选它。
  • 行数高得离谱 —— 引号字段里嵌入的换行被幼稚的解析器拆开了。用 CLI、GUI 向导或 pandas——它们都能处理引号内的换行;split(",") 不能。
  • 带重音的字符看起来像 café —— 编码错了。按 UTF-8 导入,或按文件实际保存的编码导入(通常是 Windows-1252)。
  • 前导零消失了(00501 → 501)—— 列被当成了 INTEGER。把它声明为 TEXT。
  • SUM() 返回 0 或胡言乱语 —— 值以 TEXT 存储,而且有东西没有强制转换。声明数字类型并重新导入。
  • WHERE x IS NULL 什么也找不到 —— 空字段变成了 ''。运行空字符串转 NULL 的更新。
  • 数字显示为 1.0 而不是 1 —— pandas 因为缺失值推断成了浮点数。设置 dtype={"id": "Int64"}。
  • Error: FOREIGN KEY constraint failed —— 如果你正导入到一张有 FK 约束且外键已启用的表,说明被引用的父行缺失。先插入父行,或按依赖顺序导入。

什么时候用什么

一份简短的决策指南,按我实际会做决定的顺序排列:

  • 一个文件、干净、一次 → sqlite3 CLI。三条命令,搞定。
  • 一个文件、乱,而且我想在提交前先看看 → DB Browser for SQLite。预览物有所值。
  • 我本来就泡在 DBeaver 里,或者文件需要认真的列映射 → DBeaver 的导入向导。
  • 我下周还要再做一次 → Python + pandas。脚本化它、版本化它、然后忘掉它。
  • 我需要结果表能被人类浏览和编辑 → 用上面任何一种方法导入,然后在一个合适的工具里打开 .db。

最后那一点是大多数教程止步的地方,而且它通常才是真正的目标。你很少因为想在一个数据库里放一个 CSV 而导入 CSV——你这么做是因为你想查询、过滤、连接和编辑数据。sqlite3 CLI 能把数据弄进去,但它不是一个适合居住的地方。这正是像 BaseVolt 这样的本地优先桌面应用的用武之地:把它指向你导入产生的 .db 文件,几秒钟内你就得到一个完整的 dashboard——网格、画廊、看板和 dashboard 视图,外加关系可视化——而且不改动 schema。说清楚一点,BaseVolt 不是 CSV 导入器;你用上面四种方法之一把 CSV 带进来,而 BaseVolt 是你之后使用这张表的方式。它是一个面向 macOS(Apple Silicon)和 Windows 的本地应用,连接 PostgreSQL、MySQL、SQLite 和 Cloudflare D1,你的数据永远不会离开 localhost——你可以从下载页面获取它,或在 demo.basevolt.app 试用在线演示。具体到 SQLite 这一侧,/sqlite 还有更多内容。

结论

把 CSV 导入 SQLite 是一个已解决的问题——有四种好办法,正确的那种取决于你的处境,而不是哪个工具"最好"。

  • sqlite3 CLI 对干净的一次性文件最快,但它不会替你创建带类型的表,也不会跳过你的表头,除非你告诉它。先创建表,然后 .mode csv 和 .import --skip 1。
  • DB Browser 和 DBeaver 给你预览和真正的编码/分隔符/类型控制,这在文件很乱时很重要。
  • pandas 是导入需要发生不止一次时你想要的东西。
  • 无论你用什么,记住 SQLite 的类型亲和性:如果你不声明类型,一切都是 TEXT。而且空的 CSV 字段会变成空字符串,不是 NULL——立刻修好它。

导入本身是五分钟。你花在声明类型、归一化日期、把空字符串转成 NULL 上的十分钟,才是一张你能查询的表和一张不断给你惊喜的表之间的区别。

数据进去之后,BaseVolt 就是你真正使用它的方式——一个面向你 SQLite 数据库的本地优先管理面板,无需账号,数据不离开你的机器。


如果你觉得这个有用,我写关于数据库、本地优先软件,以及构建开发者工具那些不光鲜的部分。在 X 上找我。

BasevoltBasevolt

试试 Basevolt —— 免费的本地优先数据库管理面板,支持 PostgreSQL、MySQL、SQLite 和 Cloudflare D1。

免费下载 Basevolt