SQLite 整个数据库就是一个文件。
不用装服务、不用配账号,sqlite3.connect('board.db') 就建好了。
参数化查询是防 SQL 注入的唯一正确写法。 拼字符串的写法,只要有人在名字里写一个单引号,
整条 SQL 就废了。
「我明明插进去了,查却查不到」——九成是忘了 commit。
前十一章的数据都存在文件里。CSV 能存,但查起来要全读一遍;数据库能查,而且快。
教材第 12 章开头三节是【数据库简介】【建立数据库文件】和【规划数据表结构】。
这一篇的数据库是真建的:sqlite3 建表、插入、查询、约束、事务,全都真跑。
表结构、约束报错、commit 前后差异,都是实测结果。
SQLite 跟 MySQL 有几处语义不一样,
碰到的地方正文会点出来——别把这里的结论原样搬到 MySQL 上。
一、建库就是建一个文件
sqlite3.connect('board.db') 这一句,如果文件不存在就建一个。演示里建出来 12288 字节。
SQLite 整个数据库就是一个文件,不用装服务、不用配账号。 拿来做教学、做小工具、
做手机 App 的本地存储,都够用。
建表用 create table。
演示里那张表四列:id 自增主键、name 和 body 都是不能为空的文本、created 默认填当前时间。
| 列名 | 类型 | 不能为空 | 默认值 |
|---|---|---|---|
id |
INTEGER | 否 | 自增 |
name |
TEXT | 是 | — |
body |
TEXT | 是 | — |
created |
TEXT | 是 | datetime('now','localtime') |
SQLite 的类型是「建议」不是「强制」。 它允许你把字符串塞进 integer 列——这是它跟 MySQL、
PostgreSQL 最大的区别之一。
所以 SQLite 适合小工具,不适合「数据必须严格」的场景。真要做业务系统,得用会强制检查类型的库。
created 那列的默认值 datetime('now','localtime') 值得注意:加了 localtime 才是本地时间,
不加给的是 UTC。这一条在本课反复出现,它是最常见的「时间差 8 小时」的来源。
二、写进去:参数化查询
插入用 insert into 表 (列...) values (?, ?, ?),后面跟一个元组。
那几个问号叫占位符,这套写法叫参数化查询。
演示插了 3 条,表里现在 3 条。看着平平无奇,但这是本篇最重要的一段。
为什么不写 "insert ... values ('" + name + "')"?两个原因。
第一,单引号会炸。 有人在名字里写 O'Brien,拼出来就是 values ('O'Brien'),引号不配对,
整条 SQL 报错。
第二,更严重的是 SQL 注入。 如果那个人写的是 '); drop table msg; --,
拼出来的 SQL 就变成「插一条、然后删表」。数据没了。
参数化查询把「SQL 语句」和「数据」彻底分开传:语句先编译好,数据后填进去。
填进去的东西永远只是数据,不会被当成语句的一部分。
所以规矩是:永远用问号。 一个例外都没有。哪怕这个值是你自己写死的字符串。
三、查出来:row_factory
select 默认返回元组:(1, '小王', '这批螺栓的规格确认一下')。
取值靠位置,row[1] 是名字,row[2] 是内容。
只能靠位置记哪一列是什么,字段一多就晕。 而且以后表里插一列,下标全错位。
设 con.row_factory = sqlite3.Row 之后,可以按列名取:row['name']。
也能 dict(row) 转成字典——写网页模板时特别顺手。
这一点跟第 7.2 篇那个 csv.DictReader 是同一个思路:按名字取,比按位置取稳。
sqlite3.Row 还有个好处:它同时支持下标和列名。所以老代码不用改,新代码可以按名字写。
四、约束不是装饰
建表时写 not null,就是「这一列不许为空」。
演示里给 name 传 None,直接报 IntegrityError: NOT NULL constraint failed: msg.name。
这是数据库在帮你挡脏数据。 如果你的代码有 bug 漏了一条空名字进去,数据库会拒绝,
而不是让一条脏数据躺进去三个月后才发现。
default 是另一条:让「没给」也有个合理值。
演示里 created 没给,数据库自己填了 2026-11-02 02:57:24。
这些规则写在建表语句里,不是写在代码里。 好处是:不管谁、用哪种语言往这张表里写,规矩都一样。 这一点值得多想一步。校验写在 Python 代码里,只能挡住走这段代码的写入;写在数据库里,谁也绕不过去。 关键约束要写两层。
五、改和删:where 是关键
update msg set body=? where id=? 和 delete from msg where id=?。
where 漏写,就是全表改、全表删——而且数据库不会问「你确定吗」。
演示里改了一条、删了一条,条数从 4 变 3,都是对的。但这只是因为写了 where。
养成一个习惯:
先写 select 加同样的 where,看清楚会命中哪几行,再把 select 换成 update 或 delete。
这个动作多花五秒,能省掉一次「我把整张表改了」。
而且命中几行这个信息,update 之后用 cursor.rowcount 也能看到——但那时候已经改完了。
SQLite 还有个 limit 可以配 delete(delete from msg where ... limit 1),MySQL 也支持。
但别指望它保命,limit 是给「分批删」用的,不是安全网。
六、别忘了 commit
演示里插了一条但不 commit,另开一个连接去看:0 条。commit 之后再看:1 条。
没 commit 的数据只在当前连接里看得见,别人看不到,程序一崩也没了。
「我明明插进去了,查却查不到」——九成是忘了 commit。
这是新手最高频的一个坑,而且它不报错,只是数据不在。
为什么要有这一层?因为它允许你「一组操作要么全成、要么全不成」。
转账要扣一个人的钱、加另一个人的钱,中间崩了不能只扣不加。这就是事务。
顺便:con.close() 也要写。
不关连接,文件句柄会一直挂着,Windows 上还会锁住文件,让你删都删不掉。
用 with 语句可以自动关:with sqlite3.connect(p) as con:。
但注意它管的是「事务提交」,不是「关闭连接」——这个区别很坑,真要走人还是显式 close()。
七、动手 Demo:数据库:一张表就是一堆有类型的列
真建一个 SQLite 文件,看表结构、看约束、看 commit 前后的差别。
重点看两处:not null 拒绝空值、不 commit 时另一个连接看不到数据。
import os, sqlite3, tempfile
d = tempfile.mkdtemp()
DB = os.path.join(d, 'board.db')
print('① 建库就是建一个文件')
con = sqlite3.connect(DB)
con.execute('''create table msg (
id integer primary key autoincrement,
name text not null,
body text not null,
created text not null default (datetime('now','localtime'))
)''')
con.commit()
print(" sqlite3.connect('board.db') 建出 %d 字节的文件" % os.path.getsize(DB))
print('| 列名 | 类型 | 不能为空 | 默认值 |')
print('|---|---|:---:|---|')
for cid, name, typ, notnull, dflt, pk in con.execute('pragma table_info(msg)'):
print('| %s | %s | %s | %s |' % (name, typ, '是' if notnull else '否', dflt or '—'))
print(' SQLite 整个数据库就是一个文件,不用装服务、不用配账号。')
print('② 写进去')
rows = [('小王', '这批螺栓的规格确认一下'), ('小李', '图纸已更新,见附件'),
('王工', '明天上午现场碰一下')]
for r in rows:
con.execute('insert into msg (name, body) values (?, ?)', r)
con.commit()
print(' 插了 3 条,表里现在 %d 条' % con.execute('select count(*) from msg').fetchone()[0])
print(' 注意那个问号和后面的元组。这叫参数化查询,它是防 SQL 注入的唯一正确写法。')
print('③ 查出来')
print(' 默认返回元组:')
for r in con.execute('select id, name, body from msg'):
print(' %r' % (r,))
con.row_factory = sqlite3.Row
print(' 设了 row_factory 之后:')
for r in con.execute('select id, name, body from msg'):
print(' id=%d name=%s body=%s' % (r['id'], r['name'], r['body']))
print(' 按列名取,比靠位置记住第几列是什么稳得多。也能 dict(row) 转成字典。')
print('④ 约束不是装饰')
try:
con.execute('insert into msg (name, body) values (?, ?)', (None, '空名字'))
except sqlite3.IntegrityError as e:
print(' name 传 None → IntegrityError:', e)
con.execute('insert into msg (name, body) values (?, ?)', ('小王', '再补一条'))
got = con.execute('select created from msg order by id desc limit 1').fetchone()[0]
print(' created 没给 → 数据库自己填了 %s' % got)
print(' not null 的列给 None 会直接拒绝,这是数据库在帮你挡脏数据。')
print('⑤ 改和删')
con.execute("update msg set body=? where name='小王' and id=1", ('规格已确认,按 M8 采购',))
con.commit()
print(' 改完 小王 那条:%s' % con.execute('select body from msg where id=1').fetchone()[0])
con.execute('delete from msg where id=3')
con.commit()
print(' 删掉 id=3 之后剩 %d 条' % con.execute('select count(*) from msg').fetchone()[0])
print(' update 和 delete 后面那个 where 是关键。漏写就是全表改、全表删。')
print('⑥ 别忘了 commit')
con.execute('insert into msg (name, body) values (?, ?)', ('测试', '不 commit 的那条'))
con2 = sqlite3.connect(DB)
print(' 插入但不 commit,另开一个连接看:%d 条' % con2.execute('select count(*) from msg').fetchone()[0])
con.commit()
print(' commit 之后再看:')
print(' %d 条' % con2.execute('select count(*) from msg').fetchone()[0])
con2.close()
con.close()
实跑输出:
--------------------------------------------------------------------
数据库:一张表就是一堆有类型的列
--------------------------------------------------------------------
① 建库就是建一个文件
--------------------------------------------------------------------
sqlite3.connect('board.db') 建出 12288 字节的文件
| 列名 | 类型 | 不能为空 | 默认值 |
|---|---|:---:|---|
| id | INTEGER | 否 | — |
| name | TEXT | 是 | — |
| body | TEXT | 是 | — |
| created | TEXT | 是 | datetime('now','localtime') |
SQLite 整个数据库就是一个文件,不用装服务、不用配账号。
拿来做教学、做小工具、做手机 App 的本地存储,都够用。
类型那几栏要注意:SQLite 的类型是「建议」不是「强制」,它允许你把字符串塞进 integer 列。
这是它跟 MySQL、PostgreSQL 最大的区别之一。
② 写进去
--------------------------------------------------------------------
插了 3 条,表里现在 3 条
注意那个问号和后面的元组。这叫参数化查询,它是防 SQL 注入的唯一正确写法。
拼字符串的写法:"insert ... values ('" + name + "')",只要有人在名字里写一个单引号,
整条 SQL 就废了,严重的时候能把整张表删掉。永远用问号。
③ 查出来
--------------------------------------------------------------------
默认返回元组:
(1, '小王', '这批螺栓的规格确认一下')
(2, '小李', '图纸已更新,见附件')
(3, '王工', '明天上午现场碰一下')
设了 row_factory 之后:
id=1 name=小王 body=这批螺栓的规格确认一下
id=2 name=小李 body=图纸已更新,见附件
默认给元组,只能靠位置记哪一列是什么。
设 row_factory = sqlite3.Row 之后,可以按列名取,也能 dict(row) 转成字典——写网页模板时特别顺手。
④ 约束不是装饰
--------------------------------------------------------------------
name 传 None → IntegrityError: NOT NULL constraint failed: msg.name
created 没给 → 数据库自己填了 2026-11-02 02:57:24
not null 的列给 None 会直接拒绝,这是数据库在帮你挡脏数据。default 让「没给」也有个合理值。
这些规则写在建表语句里,不是写在代码里。好处是:不管谁、用哪种语言往这张表里写,规矩都一样。
⑤ 改和删
--------------------------------------------------------------------
改完 小王 那条:规格已确认,按 M8 采购
删掉 id=3 之后剩 3 条
update 和 delete 后面那个 where 是关键。
漏写 where,就是全表改、全表删——而且数据库不会问「你确定吗」。
养成习惯:先写 select 加同样的 where,看清楚会命中哪几行,再把 select 换成 update 或 delete。
⑥ 别忘了 commit
--------------------------------------------------------------------
插入但不 commit,另开一个连接看:0 条
commit 之后再看:
1 条
没 commit 的数据只在当前连接里看得见,别人看不到,程序一崩也没了。
「我明明插进去了,查却查不到」——九成是忘了 commit。
顺便:con.close() 也要写。不关连接,文件句柄会一直挂着,Windows 上还会锁住文件,让你删都删不掉。
第一段那四行 pragma table_info 的输出就是表结构。 注意「不能为空」那一列,name 和 body 是「是」,id 是「否」——因为主键本来就不能空。 第二段那句「参数化查询是唯一正确写法」不是夸张。 拼字符串时,名字里有一个单引号就能把整条 SQL 打废。 第三段两种取法的对照要看:默认元组只能靠位置,设了 row_factory 才能按名字取。 字段一多,差别就很明显。 第四段那个 IntegrityError 是数据库在帮你挡。 要是没这条约束,一条空名字的脏数据就进去了,三个月后你才发现。 第六段那个对比最有说服力:没 commit 时另一个连接看到 0 条,commit 之后才看到。 数据其实写进日志了,只是没落盘。
30 秒实验
- 把演示第二段的插入改成拼字符串(
"insert ... values ('" + name + "')"), 然后用名字O'Brien试一次,看它报什么错。 - 把演示第四段的
not null从建表语句里去掉,重跑,看空名字能不能插进去。 - 在演示第六段末尾加一句
con.execute('select 1')但不 close,试试能不能删掉那个 db 文件(提示: Windows 上会锁住)。
常见误区
- 「拼字符串写 SQL 也一样,反正值都是自己给的。」一个单引号就能打废整条语句,严重时能删表。 永远用问号占位。
- 「插进去了就能查到。」不
commit的话,别的连接看不到,程序一崩也没了。 「明明插了却查不到」九成是忘了 commit。 - 「SQLite 是数据库,跟 MySQL 一样。」它不强制类型,字符串能塞进整数列。 做小工具够用,做业务系统得用会检查类型的库。
自测三问(点开对答案)
1. 为什么插入数据必须用 `?` 占位,不能拼字符串? —— 两个原因。一是值里有单引号会让 SQL 语法错(`O'Brien` 就是典型); 二是更严重的 SQL 注入——别人写一段带 `); drop table` 的内容,拼进去就变成「插一条顺便删表」。 参数化查询把语句和数据分开传,数据永远只是数据。 2. 插入成功了,另开一个连接却查不到,为什么? —— 因为没 `commit`。数据只在当前连接的事务里,没落盘,别的连接看不到,程序一崩也会丢。 调一下 `con.commit()` 就正常了。 3. `not null`、`default` 这些约束写在数据库里,跟写在 Python 代码里有什么区别? —— 写在代码里只能挡住走这段代码的写入,换个程序、换个人手动操作就绕过去了。 写在数据库里谁也绕不过去。所以关键约束要写两层——代码里校验一次,数据库再挡一次。下一篇把数据库接上网页:一条 select 出列表,一个 insert 存新留言。
- ☐ (本篇完,下一篇 12.2 查询报表与留言板页面)