Django form errors not showing in template (tested)

When a Django form fails is_valid() but the page shows no messages, the template is not printing {{ field.errors }} or {{ form.non_field_errors }}, or the view re-renders a new, unbound form.

Setup used in every block

common.py configures Django with the template engine and defines a form with an email, a minimum age, and a form-wide clean() rule. render() prints a template rendered with the form, whitespace collapsed:

import django
from django.conf import settings
settings.configure(TEMPLATES=[{'BACKEND': 'django.template.backends.django.DjangoTemplates'}])
django.setup()
from django import forms
from django.template import Template, Context
class SignupForm(forms.Form):
    email = forms.EmailField()
    age = forms.IntegerField(min_value=18)
    def clean(self):
        d = super().clean()
        if d.get('email', '').endswith('@test.io'):
            raise forms.ValidationError('Test addresses are not allowed.')
        return d
def render(src, form):
    out = Template(src).render(Context({'form': form}))
    print(' '.join(out.split()) or '(empty)')

1. Fields rendered one by one, errors never printed

from common import *
form = SignupForm({'email': 'nope', 'age': '12'})
print('is_valid:', form.is_valid())
print('errors:', form.errors.get_json_data())
print('--- template renders fields one by one, no errors:')
render('{{ form.email }} {{ form.age }}', form)
print('--- with field errors added:')
render('{{ form.email }}{{ form.email.errors }} {{ form.age }}{{ form.age.errors }}', form)

Output:

is_valid: False
errors: {'email': [{'message': 'Enter a valid email address.', 'code': 'invalid'}], 'age': [{'message': 'Ensure this value is greater than or equal to 18.', 'code': 'min_value'}]}
--- template renders fields one by one, no errors:
<input type="email" name="email" value="nope" maxlength="320" required aria-invalid="true" aria-describedby="id_email_error" id="id_email"> <input type="number" name="age" value="12" min="18" required aria-invalid="true" aria-describedby="id_age_error" id="id_age">
--- with field errors added:
<input type="email" name="email" value="nope" maxlength="320" required aria-invalid="true" aria-describedby="id_email_error" id="id_email"><ul class="errorlist" id="id_email_error"><li>Enter a valid email address.</li></ul> <input type="number" name="age" value="12" min="18" required aria-invalid="true" aria-describedby="id_age_error" id="id_age"><ul class="errorlist" id="id_age_error"><li>Ensure this value is greater than or equal to 18.</li></ul>

The form is invalid and form.errors has both messages, but {{ form.email }} renders only the input widget. Django 6.1 marks it aria-invalid="true", but no message appears. Add {{ form.email.errors }} after each field (or use {{ form.email.as_field_group }}, {{ form }} or {{ form.as_div }}, which include the label, the input and its errors).

2. Errors from clean() are non-field errors

from common import *
form = SignupForm({'email': 'a@test.io', 'age': '30'})
print('is_valid:', form.is_valid())
print('field errors:', {k: v for k, v in form.errors.items() if k != '__all__'})
print('non_field_errors:', list(form.non_field_errors()))
print('--- per-field template misses it:')
render('{{ form.email.errors }}{{ form.age.errors }}', form)
print('--- add non_field_errors:')
render('{{ form.non_field_errors }}', form)

Output:

is_valid: False
field errors: {}
non_field_errors: ['Test addresses are not allowed.']
--- per-field template misses it:
(empty)
--- add non_field_errors:
<ul class="errorlist nonfield"><li>Test addresses are not allowed.</li></ul>

An error raised in the form's clean() (or added with add_error(None, ...)) belongs to no field: every per-field .errors is empty and the template prints nothing. Put {{ form.non_field_errors }} at the top of the form.

3. The view replaces the bound form

from common import *
def view_bug(data):
    form = SignupForm(data)
    if form.is_valid():
        return 'saved'
    form = SignupForm()          # BUG: replaces the bound form
    return form
def view_ok(data):
    form = SignupForm(data)
    if form.is_valid():
        return 'saved'
    return form                  # re-render the same bound form
for v in (view_bug, view_ok):
    f = v({'email': 'nope', 'age': '12'})
    print(v.__name__, 'is_bound:', f.is_bound, 'errors:', dict(f.errors))

Output:

view_bug is_bound: False errors: {}
view_ok is_bound: True errors: {'email': ['Enter a valid email address.'], 'age': ['Ensure this value is greater than or equal to 18.']}

If the view creates a fresh SignupForm() after is_valid() fails, the template gets an unbound form with no errors (and no submitted values). Pass the same bound form back to render() instead. A redirect() after an invalid POST has the same effect, because the next GET builds a new form; only redirect after a successful save.

Related: Django admin "Please correct the error below", Django CSRF verification failed.

Frequently asked questions

Why are my Django form errors not displayed?

Rendering {{ form.field }} prints only the widget. Add {{ form.field.errors }} and {{ form.non_field_errors }}, and pass the same bound form back to the template.

Where do errors raised in a form's clean() method appear?

In form.non_field_errors(), not on any field. The template must render {{ form.non_field_errors }}.

Written with AI assistance. Measured on 2026-10-23 with Django 6.1.2 and Python 3.14.7 (python:3-alpine in Docker). Each block was run verbatim and the output pasted unedited. PlainHeaders is our extension; Python and Django are free.