Every Django form follows a predictable lifecycle from instantiation through validation to data access. Understanding this lifecycle is the foundation for writing forms that behave exactly as you expect.
Key Concepts
- Bound form: A form that has received user data via the
dataparameter. - Unbound form: A form with no submitted data, for rendering only.
- full_clean(): Triggered by
is_valid(), orchestrates all validation. - cleaned_data: Dictionary of validated field values.
- BoundField: Pairs a field definition with its submitted value and errors.
Real World Context
When debugging why a form silently rejects valid-looking input, the lifecycle diagram is your map. A registration form where clean_username() normalizes to lowercase while clean() verifies passwords match shows exactly where each check belongs.
Deep Dive
The Form Lifecycle
Understanding how Django processes forms helps you customize their behavior effectively.
Form Initialization
pythonfrom django import forms class ContactForm(forms.Form): name = forms.CharField(max_length=100) email = forms.EmailField() message = forms.CharField(widget=forms.Textarea) # Unbound form (no data) form = ContactForm() print(form.is_bound) # False # Bound form (with data) form = ContactForm(data={'name': 'John', 'email': 'john@example.com'}) print(form.is_bound) # True # Form with initial values (not bound!) form = ContactForm(initial={'name': 'Default Name'}) print(form.is_bound) # False
The Validation Process
form.is_valid() called
│
▼
┌───────────────────────┐
│ form.full_clean() │
└───────────────────────┘
│
├───────────────────────────┐
▼ │
┌───────────────────────┐ │
│ _clean_fields() │ │
│ For each field: │ │
│ 1. field.clean() │ ──► field validation
│ 2. clean_<field>() │ ──► custom field validation
└───────────────────────┘ │
│ │
▼ │
┌───────────────────────┐ │
│ _clean_form() │ │
│ • form.clean() │ ──► cross-field validation
└───────────────────────┘ │
│ │
▼ │
┌───────────────────────┐ │
│ _post_clean() │ │
│ (ModelForm specific) │ │
└───────────────────────┘ │
│ │
▼ │
form.cleaned_data ◄──────────────┘
form.errors
Field Validation Order
pythonclass RegistrationForm(forms.Form): username = forms.CharField(max_length=30) email = forms.EmailField() password = forms.CharField(widget=forms.PasswordInput) confirm_password = forms.CharField(widget=forms.PasswordInput) def clean_username(self): """Called after field.clean() for username.""" username = self.cleaned_data['username'] if User.objects.filter(username=username).exists(): raise forms.ValidationError('Username already taken') return username def clean_email(self): """Called after field.clean() for email.""" email = self.cleaned_data['email'] if User.objects.filter(email=email).exists(): raise forms.ValidationError('Email already registered') return email def clean(self): """Called after all field validations.""" cleaned_data = super().clean() password = cleaned_data.get('password') confirm = cleaned_data.get('confirm_password') if password and confirm and password != confirm: raise forms.ValidationError('Passwords do not match') return cleaned_data
Accessing Form Data
pythondef contact_view(request): form = ContactForm(request.POST or None) if form.is_valid(): # Access validated data name = form.cleaned_data['name'] email = form.cleaned_data['email'] # cleaned_data only exists after is_valid() print(form.cleaned_data) else: # Access errors print(form.errors) # Dict-like: {'field': ['error1', 'error2']} print(form.errors.as_json()) # JSON format print(form.non_field_errors()) # Errors from clean()
BoundField Objects
pythonform = ContactForm(data={'name': 'John'}) # Access bound fields name_field = form['name'] # BoundField object print(name_field.name) # 'name' print(name_field.value()) # 'John' print(name_field.label) # 'Name' print(name_field.id_for_label) # 'id_name' print(name_field.errors) # [] print(name_field.is_hidden) # False print(name_field.html_name) # 'name' # Iterate over form fields for field in form: print(f"{field.label}: {field.value()}")
Common Pitfalls
- Confusing
initialwithdata--initial=does NOT make a form bound. Onlydata=does. - Accessing
cleaned_databeforeis_valid()-- RaisesAttributeError. - Forgetting to return from
clean_<field>()-- The field value becomesNone.
Best Practices
- Always call
super().clean()first -- Preserves parent validation including unique constraints. - Use
clean_<field>()for single-field,clean()for cross-field -- Keeps errors associated with correct fields. - Use
.get()inclean()-- Failed fields are absent fromcleaned_data.
Summary
- A form is bound when passed
data=, unbound otherwise. is_valid()triggersfull_clean()which runs_clean_fields(),_clean_form(),_post_clean().clean_<fieldname>()runs per-field validation and must return the cleaned value.clean()handles cross-field validation and must returncleaned_data.BoundFieldobjects provide field value, label, and errors in templates.
Code Examples
python
from django import forms
class ContactForm(forms.Form):
name = forms.CharField(max_length=100)
email = forms.EmailField()
message = forms.CharField(widget=forms.Textarea)
# Unbound form (no data yet)
form = ContactForm()
print(form.is_bound) # False
# Bound form (with user data)
form = ContactForm(data={'name': 'Alice', 'email': 'alice@example.com'})
print(form.is_bound) # True
print(form.is_valid()) # False (message is missing)