bigywater.tech
Django 系列 · 第七篇

一个字段校验失败,其它字段会不会被跳过?Form.clean() 里忘记判空,为什么会真实抛 KeyError?

上一篇讲 Signal.send() 遇到一个 receiver 抛异常就整个中断广播——Form 的校验流水线走的是完全相反的设计:哪怕好几个字段同时不合法,也要把每一个都跑一遍、把所有错误一次性收集给用户看。本机真实实测了这条"绝不提前中断"的流水线,以及它带来的一个很容易踩、真实会抛 KeyError 的副作用。

2 个字段都报错
本机真实实测:username 和 age 同时不合法,errors 里两个都在,不是只报第一个
真实 KeyError
本机真实实测:password2 校验失败后,clean() 里 cleaned_data['password2'] 真实抛异常

真实源码:errors 是惰性属性,full_clean() 是三步流水线

form.is_valid() 背后真正做事的是 full_clean(),而它第一次真正跑起来,是在你访问 form.errors 的那一刻。

django/forms/forms.py · django @ 6.0.7, L197 @property def errors(self): """Return an ErrorDict for the data provided for the form.""" if self._errors is None: self.full_clean() # 只有第一次访问 errors 才会真的跑校验 return self._errors def is_valid(self): return self.is_bound and not self.errors # is_valid() 本质就是"errors 是不是空的"
django/forms/forms.py · django @ 6.0.7, L324 def full_clean(self): self._errors = ErrorDict(renderer=self.renderer) if not self.is_bound: return self.cleaned_data = {} ... self._clean_fields() # 第一步:逐个字段清洗 self._clean_form() # 第二步:整表级别的 clean() self._post_clean() # 第三步:留给 ModelForm 做模型级校验的钩子,基类里什么都不做

三步是固定顺序、无条件全部执行的——不会因为第一步里某个字段失败就跳过第二步。这是理解后面几个反直觉行为的关键。

真实实测:一个字段挂了,其它字段照样会被清洗

跟上一篇 Signal.send() 一个 receiver 抛异常就整个中断完全相反,_clean_fields() 的 try/except 是放在 for 循环内部的。

django/forms/forms.py · django @ 6.0.7, L341 def _clean_fields(self): for name, bf in self._bound_items(): field = bf.field try: self.cleaned_data[name] = field._clean_bound_field(bf) if hasattr(self, "clean_%s" % name): value = getattr(self, "clean_%s" % name)() self.cleaned_data[name] = value except ValidationError as e: self.add_error(name, e) # 记下这个字段的错误,循环继续,不会 break
真实实测
class SignupForm(forms.Form):
    username = forms.CharField(min_length=3, validators=[not_admin])
    age = forms.IntegerField(min_value=18)

f = SignupForm(data={'username': 'ad', 'age': '10'})   # 两个字段都不合法
f.is_valid()
print(dict(f.errors))
{'username': ['Ensure this value has at least 3 characters (it has 2).'], 'age': ['Ensure this value is greater than or equal to 18.']}

username 先失败,但 age 照样被真实清洗了一遍,两个错误同时出现在 errors 里——这正是表单校验应该有的体验:一次提交,把所有问题都告诉用户,而不是改一个报一个、来回提交好几次才能把所有错误都暴露出来。

真实源码 + 实测:一个字段身上的多个 validator,也是"全跑完再报"

不只是字段之间不会互相打断,同一个字段身上挂的多个 validators,失败了也不会在第一个就停。

django/forms/fields.py · django @ 6.0.7, L187 def run_validators(self, value): if value in self.empty_values: return errors = [] for v in self.validators: try: v(value) except ValidationError as e: errors.extend(e.error_list) # 收集,不 return、不 break if errors: raise ValidationError(errors) # 循环跑完了,才把攒的一堆错误一次性抛出去
真实实测
# username 同时违反三条规则:太短 + 有空格 + 不是全小写
class F(forms.Form):
    username = forms.CharField(min_length=6, validators=[no_space, must_be_lower])

f = F(data={'username': 'AB C'})
f.is_valid()
print(f.errors['username'])
不能有空格 必须全小写 Ensure this value has at least 6 characters (it has 4).

三条规则全违反,三条错误全部出现,顺序也如实反映了 self.validators 列表本身的顺序——自定义的 validators=[...] 排在前面,CharField.__init__ 里自动追加的 MinLengthValidator 排在最后,因为它是在 super().__init__(**kwargs)(处理用户传入的 validators)之后才 append 进去的。

真实实测:clean() 无条件会跑,cleaned_data 里悄悄缺了一个 key

_clean_form() 不管前面 _clean_fields() 有没有报错,都会去调用 self.clean()——但失败的字段,压根不会出现在 cleaned_data 里。

django/forms/forms.py · django @ 6.0.7, L298 def add_error(self, field, error): ... for field, error_list in error.items(): ... self._errors[field].extend(error_list) if field in self.cleaned_data: del self.cleaned_data[field] # 这个字段哪怕之前侥幸写进去过,报错了也会被删掉

常见的"两次密码必须一致"写法,如果不用 .get() 而是直接用下标取值,一旦 password2 自己先因为太短校验失败,clean() 里的下标访问会真实炸掉:

真实实测
class SignupForm(forms.Form):
    password = forms.CharField(min_length=6)
    password2 = forms.CharField(min_length=6)

    def clean(self):
        cleaned = super().clean()
        if cleaned['password'] != cleaned['password2']:   # 没用 .get(),假设两个字段都在
            raise ValidationError('两次密码不一致')
        return cleaned

f = SignupForm(data={'password': '123456', 'password2': '123'})  # password2 太短
f.is_valid()
KeyError: 'password2'

password2 自己的"至少 6 个字符"报错,已经通过 add_error() 把它从 cleaned_data 里删掉了;_clean_form() 完全不知道也不关心这件事,照样调用 self.clean(),于是 cleaned['password2'] 这行代码直接命中一个不存在的 key。这也是为什么 Django 官方文档反复强调 Form.clean() 里取值要用 cleaned_data.get('password2'),不是编码洁癖,是真的会在生产环境炸出一个用户提交了几个字符就能触发的 500。

对照上一篇:Signal 的 send() 是"一个 receiver 炸了,后面全部不跑";Form 的这条流水线是"一个字段炸了,后面全部照跑,但跑的时候要自己留神它可能已经不在数据里了"。两种设计都不是疏忽,是明确的取舍——只是踩坑的方式完全不同。

真实实测:errors 会被缓存,is_valid() 调用几次都只跑一次校验

回到最开始那个惰性属性——self._errors is None 这个判断顺带做了缓存。

真实实测
call_count = {'n': 0}
def counting_validator(value):
    call_count['n'] += 1

class F(forms.Form):
    name = forms.CharField(validators=[counting_validator])

f = F(data={'name': 'alice'})
f.is_valid(); f.is_valid()
dict(f.errors)
print('校验函数被调用次数:', call_count['n'])
校验函数被调用次数: 1

调用了两次 is_valid() 加一次 errors,校验函数真实只跑了一次——full_clean() 只会在 self._errors 还是 None 的时候执行一次,之后不管你怎么反复检查这个表单,拿到的都是第一次跑出来的那份结果。如果你在校验通过之后又手动改了 form.data 里的内容,errors/cleaned_data 也不会跟着刷新——这不是 bug,只是 Form 从设计上就假定一次绑定的数据只会被清洗一次。

演示:一次提交,几个字段并行走完清洗流水线

跟上面 username/age 的真实实验完全同构:两个字段都不合法,看流水线怎么把两份错误都收集出来。

Form._clean_fields() 执行过程演示
字段状态结果

数据直接取自上面真实实测的 SignupForm(username 太短、age 小于 18,外加一个合法的 email 字段做对照),演示动画只是把"逐个清洗、互不打断、最后一起报错"这个规则可视化,最终状态和真实的 f.errors 逐条核对过一致。

参考与说明

  • 本文全部真实实验(两个字段同时失败仍各自被清洗、一个字段多个 validator 的错误被一次性收集、Form.clean() 里下标访问触发真实 KeyError、errors 的惰性缓存)均在本机真实运行的 Django 6.0.7 上完成(直接用 django.forms.Form 构造真实表单类并绑定真实数据校验,不是 mock),数据未做删改。
  • 源码引用(django/forms/forms.py 的 BaseForm.errors/is_valid/full_clean/_clean_fields/add_error、django/forms/fields.py 的 Field.clean/run_validators)取自本机通过 pip 安装的 Django 6.0.7 包本身,并逐字节比对确认与 django/django 仓库 6.0.7 标签完全一致。
  • 演示动画的清洗流水线(逐字段清洗、错误收集、互不打断)由 Python 独立实现并交叉验证过——参数直接取自真实实测的 SignupForm 校验结果,每一步字段状态都核对过与真实 f.errors 一致。
  • 没有涉及:ModelForm 在 _post_clean() 里怎么调用 instance.full_clean() 做模型级校验(unique/unique_together 这类只有 ModelForm 才会检查的约束)、FormSet 的 management_form 和 empty_permitted 短路逻辑、widget.value_from_datadict() 怎么从多值字段(比如日期的年/月/日三个下拉框)里拼出一个值这类渲染层细节。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电
京ICP备2026067502号-1京公网安备11010502063487号