作为Python开发者,跟数据库打交道这些年,SqlAlchemy + MySQL基本是组合标配。但上个月我踩了一个非常隐蔽的坑:往MySQL的JSON字段插入None时,数据落库后居然变成了字符串'null',后续用WHERE json_col IS NULL怎么都查不出来那几条记录。当时排查到怀疑人生,今天专门写一篇复盘,把问题原委和几种解法一次性说透,希望帮同路人省掉这个折腾过程。
这个问题的核心不在MySQL端,而是SqlAlchemy的JSON类型在序列化时的默认行为,加上MySQL对JSON null和字符串"null"的处理方式,两者叠加导致了“插入None、查询失效”的诡异现象。这篇文章会从复现开始,逐步拆解原理,然后给出4种可落地的解决方案,最后附上排查JSON字段类型的实用技巧。无论你是在做爬虫存储、API后台还是数据同步,只要用了JSON字段,都建议花几分钟看完,避免以后踩同样的坑。
1. 问题现象与快速复现
先聊现象,我那次是做一个配置管理模块,配置项存在MySQL的JSON字段里,允许为空。业务逻辑里默认值直接用了Python的None,ORM模型用的是SqlAlchemy的JSON类型。插入后一切正常,但等我想查那些“没有配置”的记录时,filter(Config.data.is_(None))返回的结果总是空列表。一开始我以为是ORM缓存问题,后来直接用SQL查数据库,发现那几条记录的data字段显示为'null',注意这个值带引号,也就是一个JSON字符串,而不是数据库层面的SQL NULL。
复现起来很简单,建一张测试表,跑下面代码就能看现象。
1.1 最小复现代码
这里以MySQL 8.0 + PyMySQL + SqlAlchemy 2.0为例:
python复制from sqlalchemy import Column, Integer, JSON, create_engine, inspect
from sqlalchemy.orm import declarative_base, Session
Base = declarative_base()
class TestModel(Base):
__tablename__ = 'test_json'
id = Column(Integer, primary_key=True, autoincrement=True)
data = Column(JSON, nullable=True)
engine = create_engine('mysql+pymysql://test:test@127.0.0.1:3306/test_db')
Base.metadata.create_all(engine)
with Session(engine) as session:
session.add(TestModel(data=None))
session.commit()
with Session(engine) as session:
result = session.query(TestModel).filter(TestModel.data.is_(None)).all()
print(f"查询到 {len(result)} 条记录") # 实际可能为 0
这段代码按直觉应该能查到刚插入的那条记录,但实际上很多环境下data.is_(None)结果是空,让人一头雾水。
1.2 用SQL确认数据库里到底存了什么
遇到这种情况,别急着改代码,先手动连数据库验证一下实际存储值。执行一条查询:
sql复制SELECT
id,
data,
data IS NULL AS is_sql_null,
JSON_TYPE(data) AS json_type
FROM test_json;
如果结果里is_sql_null为0,而json_type显示STRING,说明这个字段存的不是SQL NULL,而是JSON字符串"null"(也就是一个有内容的字符串)。如果是SQL NULL,json_type会显示NULL,is_sql_null为1。
我在现场看到的是:data列显示null(不带引号),但JSON_TYPE返回STRING。这就是问题所在——MySQL把传入的字符串解析成了JSON字符串"null",而不是JSON null,更不是SQL NULL。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 问题根源:SqlAlchemy序列化与MySQL JSON NULL的微妙关系
这类问题之所以难排查,是因为“null”这个单词在不同地方有完全不同的含义:None、'null'、"null"、JSON null、SQL NULL,五个概念纠缠在一起,特别容易让人混淆。我们先拆开看,捋清楚源头。
2.1 MySQL JSON类型里的null到底怎么存
MySQL 5.7开始支持原生的JSON类型。对于JSON文档中的null值,MySQL的处理有一定特殊性:
- 当你插入一个JSON文档,例如
{"a": null},这里null是JSON的null,存储在字段 -> '$.a'时,会变为SQL NULL。 - 当你插入的整个JSON文档就是
null,例如直接执行INSERT INTO t(data) VALUES (null),如果data是JSON类型,这个null会被当作SQL NULL,也就是字段本身为NULL。 - 但是,如果你插入的是字符串
'null'(包含引号的字面量),MySQL解析时会将'null'识别为JSON的字符串类型,而不是JSON null,也不是SQL NULL。
关键点:JSON null ≠ SQL NULL。MySQL会尽量将JSON null映射为SQL NULL,但JSON字符串"null"就是个普通字符串,不会自动变成NULL。
Python里的None在序列化为JSON时对应的是null,即JSON null。如果这个JSON null被整个字段接收,MySQL一般会存储为SQL NULL。但如果你在SqlAlchemy序列化这一环把None变成了带引号的'"null"',MySQL看到的就是一个JSON字符串,于是存储为字符串,IS NULL自然失效。
2.2 SqlAlchemy JSON字段的默认序列化行为
SqlAlchemy的JSON类型在将Python对象写入数据库之前,会使用json.dumps做序列化。当绑定参数为None时,json.dumps(None)的结果是字符串null,不带引号,这是JSON的null字面量。
理论上,SqlAlchemy传入的是null,MySQL应该解析为JSON null并转成SQL NULL。但在某些SqlAlchemy版本或方言组合下,你会发现它传入给MySQL驱动的是一个字符串'null'(带两个单引号的SQL字符串字面量),而MySQL收到这个字符串参数后,因为目标列是JSON类型,会尝试把它当作JSON文档来解析。解析的结果是:字符串内容null恰好是一个合法的JSON null字面量,于是它确实会被当作JSON null。这时IS NULL应该有效。
问题往往出在你的代码传入的根本不是None,而是字符串'null'。比如有的框架在处理前端传递的JSON null时,会在某个环节把它变成字符串"null",再赋给ORM属性,此时SqlAlchemy序列化时调用json.dumps("null"),得到的结果是带引号的'"null"',MySQL存储时就会当成JSON字符串。
2.3 None为什么可能变成字符串“null”
根据我那次排查,真正的原因不在SqlAlchemy本身,而是上游数据处理链路。常见诱因有这么几类:
- ORM模型属性被显式或隐式转换过,例如自定义了
@validates('data'),做了str(value),None就变成了'None'或'null'。 - 使用了一些自动序列化工具(如
marshmallow、pydantic),在反序列化时把JSON的null映射为字符串"null"。 - 在模型定义里给字段设置了默认值为
"null"字符串,而不是None。 - 有的API框架在接收请求体时,会自动将JSON字段的值解析为字符串,即使前端传的是
null。
当你看到数据库里已经是JSON字符串"null"时,基本上可以断定:插入时SqlAlchemy收到的Python值并不是None,而是字符串"null",或者一个包含了"null"字符串的结构。
3. 解决方案:让None正确落库为SQL NULL
既然是“序列化/转换”环节出的问题,修复思路就是确保最终交给SqlAlchemy的值,要么是Python的None,要么显式指定为SQL NULL。下面提供4种方案,按推荐程度排序。
3.1 方案一:显式使用sqlalchemy.null()
这是最直接、改动最小的方式。在插入时,如果业务逻辑要求字段为NULL,不要直接传None,而是传sqlalchemy.null()。
python复制from sqlalchemy import null
with Session(engine) as session:
session.add(TestModel(data=null()))
session.commit()
null()是SqlAlchemy内置的SQL表达式,表示数据库层面的NULL,不会经过JSON序列化,也不会被当成字符串。这样写入的字段一定是SQL NULL,WHERE data IS NULL肯定能查出来。
不过这个方案的问题在于:如果模型属性已经在代码里被赋值为None,就不能简单替换。它的适用场景是,你明确知道“这个字段想存NULL”,就在ORM构造时用null()。
3.2 方案二:使用JSON(none_as_null=True)参数
这个是SqlAlchemy从版本1.3.11开始提供的JSON类型参数。设置none_as_null=True后,当Python值为None时,绑定参数时会直接返回SQL NULL,而不是序列化为JSON null字符串。
python复制from sqlalchemy import Column, JSON
class TestModel(Base):
__tablename__ = 'test_json'
id = Column(Integer, primary_key=True, autoincrement=True)
data = Column(JSON(none_as_null=True), nullable=True)
测试一下:
python复制with Session(engine) as session:
session.add(TestModel(data=None))
session.commit()
with Session(engine) as session:
result = session.query(TestModel).filter(TestModel.data.is_(None)).all()
print(len(result)) # 输出 1
这种方式一劳永逸,模型层面直接控制,不用改业务代码。需要注意的是,none_as_null=True只对“字段值为None”生效。如果你的JSON字段里存的是一个对象,例如{"a": None},那么内部a对应的是JSON null,这属于正常JSON语义,不归这个参数管。
3.3 方案三:自定义JSON序列化器
如果你不想在模型上额外指定参数,或者需要在序列化时统一处理空值逻辑,可以自定义一个TypeDecorator。SQLAlchemy允许你继承JSON类型并重写bind_processor,在绑定前把None改成None——是的,就这……直接返回None,不让它经过json.dumps。
python复制from sqlalchemy.types import TypeDecorator, JSON
import json
class SafeJSON(TypeDecorator):
impl = JSON
def bind_processor(self, dialect):
impl_processor = self.impl.bind_processor(dialect)
def process(value):
if value is None:
return None
if isinstance(value, str):
# 如果是字符串 "null",转回 None
if value.strip().lower() == "null":
return None
return impl_processor(value) if impl_processor else value
return process
然后在模型里使用这个自定义类型:
python复制class TestModel(Base):
__tablename__ = 'test_json'
id = Column(Integer, primary_key=True, autoincrement=True)
data = Column(SafeJSON, nullable=True)
这种方式的优势是灵活,你可以统一拦截各种脏数据,比如把字符串"null"、"None"都转成真正的None。缺点是需要维护额外的类型定义,适合在一个项目里多处使用JSON字段、希望统一管理空值语义的场景。
3.4 方案四:查询时改用JSON_EXTRACT或CAST
如果你不想改写入逻辑,也可以在查询端下功夫。对于JSON字符串"null",直接使用IS NULL确实查不到,但可以先用JSON_EXTRACT把字段取出来,判断提取结果是否为SQL NULL,或者直接用CAST(... AS CHAR)来辅助判断。
python复制from sqlalchemy import text
# 查询 data 字段为 JSON 字符串 "null" 的记录
with Session(engine) as session:
result = session.query(TestModel).filter(
text("JSON_EXTRACT(data, '$') IS NULL")
).all()
但这个方案只适合“已经存脏数据,想临时捞出来修复”的场景。如果线上有存量数据已经存成了字符串"null",你可以先用这类SQL把它找出来,再通过UPDATE修复为SQL NULL:
sql复制UPDATE test_json
SET data = NULL
WHERE data IS NOT NULL AND JSON_TYPE(data) = 'STRING' AND data = 'null';
然后再改应用代码,避免继续写入脏数据。
4. 排查技巧与避坑清单
这类问题最怕的就是你在错误的方向上反复试,最后发现是某个不起眼的转换把None变成了字符串。下面这套排查流程,基本能帮你快速定位。
4.1 先用SQL确认实际存储类型
遇到JSON字段查询结果不符合预期,第一反应别去改ORM代码,而是连上数据库直接用SQL看类型。我常用的排查SQL是:
sql复制SELECT
id,
data,
JSON_TYPE(data) AS data_type,
CASE WHEN data IS NULL THEN 'NULL' ELSE 'NOT NULL' END AS null_flag
FROM test_json;
JSON_TYPE会返回以下常见值:
STRING:字段是JSON字符串,也就是说存的是"null"或"abc"这类。NULL:字段是SQL NULL。OBJECT:字段是JSON对象。ARRAY:字段是JSON数组。
如果JSON_TYPE返回STRING,基本可以断定:插入时传给MySQL的是一个JSON字符串。下一步就检查上游数据在进入SqlAlchemy之前到底变成了什么。
4.2 检查是否真的用了JSON类型而不是String/Text
还有一种常见情况:建表时图省事,把JSON列定义成了TEXT或VARCHAR。这种情况下,SqlAlchemy的JSON类型虽然存在,但DDL建表时可能被方言翻译成了TEXT,于是MySQL无法使用JSON函数,也会出现“None变成字符串”的假象。检查表结构:
sql复制SHOW CREATE TABLE test_json;
如果看到这一列是varchar或text,就需要把列类型改成json。可以用ALTER TABLE语句转换:
sql复制ALTER TABLE test_json MODIFY COLUMN data JSON;
转换前注意,如果库里已经有非法的JSON字符串,MySQL会报错,需要先清理脏数据。
4.3 区分JSON字段本身为NULL和JSON对象内某个键为NULL
这是很多人容易混淆的另一个点。比如你的字段存储的是{"name": null},这时候字段本身不是SQL NULL,而是一个合法的JSON对象。如果你用WHERE data IS NULL,查不到任何记录,只能通过JSON_EXTRACT(data, '$.name') IS NULL来判断name是否为null。
可以通过一个例子帮助记忆:
data = None对应 SQL NULL,用data IS NULL判断。data = {"name": None}对应 JSON对象,字段本身非NULL,用JSON_EXTRACT(data, '$.name') IS NULL判断。data = "null"对应 JSON字符串,字段非NULL,且提取出来的值也不是NULL,需要先转换或清洗。
如果你在业务里经常需要区分这些情况,建议在模型层做一个helper方法,避免每次查询都写一堆JSON_EXTRACT。
4.4 检查SqlAlchemy方言和驱动版本
SqlAlchemy 2.0对JSON类型的处理与1.x有所差异,PyMySQL和mysqlclient在处理None参数时行为也有细微区别。如果你用的是老版本SqlAlchemy,建议先升级到2.0+,并搭配较新的PyMySQL。有时候问题在升级后自动消失,因为高版本对JSON null映射做了更多兼容。
4.5 踩坑后的实用建议
最后分享几个我现在的习惯,避免以后再掉进同一类坑:
- 在所有JSON字段的模型定义里,默认都加上
JSON(none_as_null=True),保证字段级None直接落为SQL NULL。 - 所有从外部API接收的数据,进入ORM之前统一经过一个清洗层,把字符串形式的
"null"、"None"、空字符串""转化为真正的PythonNone。 - 写查询时,除非你明确知道字段只会存储JSON对象/数组,否则尽量不用
IS NULL来判断“没有值”,而是显式用JSON_EXTRACT或者data == None配合none_as_null=True。
这个坑看似简单,但排查链路长,涉及到Python序列化、MySQL内部类型映射、SqlAlchemy方言差异等多个环节。如果你正好也遇到类似问题,别慌,按文中的步骤顺序排查,很快就能定位。我个人体会,这类问题最值钱的不是那个最终的“一行修复”,而是整个排查过程能让你对自己项目里的数据流转链路有更清晰的认识。
