Module 8: Forms & Validation — Complete Tutorial
🎯 What You Will Learn in This Module
By the end of this tutorial, you will:
- Understand the difference between
FormandModelForm - Build forms automatically from your models using
ModelForm - Customize form rendering with widgets and HTML attributes
- Implement field-level validation with
clean_<field>() - Implement form-level validation with
clean() - Protect forms from spam with a honeypot field
- Validate email addresses properly
- Handle form submission in both FBVs and CBVs
- Understand CSRF protection and why it matters
📖 1. Why Use Django Forms?
You could write HTML forms manually and process request.POST data directly. But Django forms give you superpowers:
| Feature | Manual HTML | Django Forms |
|---|---|---|
| HTML generation | Write every input by hand | Auto-generated from model fields |
| Validation | Check every field manually | Automatic + custom validators |
| Error messages | Write your own | Auto-generated, customizable |
| CSRF protection | Easy to forget | Included automatically |
| Data cleaning | Manual type conversion | Automatic trimming, type casting |
| Security | Vulnerable to injection | Escaped and sanitized by default |
💡 Think of Django forms as a contract between your user and your database. They ensure only clean, valid data reaches your models.
🏗️ 2. Form vs. ModelForm
Django provides two base classes for forms:
| Class | Use Case | Data Source |
|---|---|---|
forms.Form | Standalone forms not tied to models | Contact forms, search forms, login forms |
forms.ModelForm | Forms that create/update model instances | Blog posts, user profiles, comments |
Rule: If your form saves data to a database model, use ModelForm. If it's just sending an email or searching, use Form.
📝 3. Building a ModelForm for Posts
Let's create a form for our Post model. Create blog/forms.py:
1# blog/forms.py 2 3from django import forms 4from django.core.exceptions import ValidationError 5from .models import Post, Comment 6 7 8class PostForm(forms.ModelForm): 9 """ 10 A ModelForm for creating and editing blog posts. 11 Automatically generates fields from the Post model. 12 """ 13 14 class Meta: 15 # The model this form is based on 16 model = Post 17 18 # Which model fields to include in the form 19 fields = ['title', 'content', 'category', 'tags', 'featured_image', 'status'] 20 21 # Customize how each field renders in HTML 22 widgets = { 23 'title': forms.TextInput(attrs={ 24 'class': 'form-control', 25 'placeholder': 'Enter a catchy title' 26 }), 27 'content': forms.Textarea(attrs={ 28 'class': 'form-control', 29 'rows': 10, 30 'placeholder': 'Write your post content here...' 31 }), 32 'category': forms.Select(attrs={ 33 'class': 'form-select' 34 }), 35 'tags': forms.CheckboxSelectMultiple(), 36 'status': forms.Select(attrs={ 37 'class': 'form-select' 38 }), 39 } 40 41 def clean_title(self): 42 """ 43 Field-level validation: checks the title field individually. 44 This method is automatically called during form validation. 45 """ 46 title = self.cleaned_data['title'] 47 48 if len(title) < 10: 49 raise ValidationError('Title must be at least 10 characters long.') 50 51 if len(title) > 200: 52 raise ValidationError('Title cannot exceed 200 characters.') 53 54 # Always return the cleaned value 55 return title 56 57 def clean(self): 58 """ 59 Form-level validation: checks multiple fields together. 60 Called after all field-level clean methods have passed. 61 """ 62 cleaned_data = super().clean() 63 status = cleaned_data.get('status') 64 content = cleaned_data.get('content') 65 66 # Business rule: published posts must have substantial content 67 if status == 'published' and content and len(content) < 100: 68 # Attach error to a specific field 69 raise ValidationError({ 70 'content': 'Published posts must have at least 100 characters of content.' 71 }) 72 73 return cleaned_data
Understanding class Meta
The Meta inner class tells Django how to build the form:
| Attribute | Purpose |
|---|---|
model | Which Django model to base the form on |
fields | Which model fields to include (whitelist approach) |
exclude | Which model fields to exclude (blacklist approach) |
widgets | HTML widget and attributes for each field |
labels | Custom labels displayed above fields |
help_texts | Explanatory text shown below fields |
error_messages | Custom error messages per field |
🚨 Security tip: Always use
fields = [...](explicit inclusion) instead ofexclude = [...]. If you add a sensitive field to your model later (likeis_admin), anexcludeapproach might accidentally expose it.
🎨 4. Widgets — Controlling HTML Output
Widgets determine how a form field renders as HTML:
| Widget | HTML Output | Best For |
|---|---|---|
TextInput | <input type="text"> | Short text: titles, names |
Textarea | <textarea> | Long text: content, descriptions |
EmailInput | <input type="email"> | Email addresses |
URLInput | <input type="url"> | Web links |
NumberInput | <input type="number"> | Quantities, prices |
Select | <select> dropdown | ForeignKey, choices |
CheckboxSelectMultiple | Multiple checkboxes | ManyToMany fields |
RadioSelect | Radio buttons | Single choice, few options |
DateInput | <input type="date"> | Date fields |
ClearableFileInput |
Customizing widgets with attributes:
1widgets = { 2 'title': forms.TextInput(attrs={ 3 'class': 'form-control', # CSS class for styling 4 'placeholder': 'Enter title', # Hint text inside input 5 'autofocus': True, # Focus on page load 6 'maxlength': 200, # HTML5 validation 7 }), 8}
🛡️ 5. Validation Deep Dive
Django runs validation in three stages. Understanding this flow helps you write better validators.
Stage 1: Field Validation (built-in)
↓ Is the email format valid? Is the number within range?
Stage 2: clean_<field>() (custom per field)
↓ Is the title at least 10 characters? Is the slug unique?
Stage 3: clean() (custom form-wide)
↓ Do password and confirm_password match? Is content long enough for published status?
Stage 4: Model Validation
↓ Does this violate model constraints (unique, max_length)?
Field-Level Validation (clean_<field>())
1def clean_title(self): 2 title = self.cleaned_data['title'] 3 4 # Check minimum length 5 if len(title) < 10: 6 raise ValidationError('Title must be at least 10 characters.') 7 8 # Check for forbidden words 9 forbidden_words = ['spam', 'scam', 'click here'] 10 if any(word in title.lower() for word in forbidden_words): 11 raise ValidationError('Title contains forbidden words.') 12 13 return title
Form-Level Validation (clean())
1def clean(self): 2 cleaned_data = super().clean() 3 4 password = cleaned_data.get('password') 5 confirm_password = cleaned_data.get('confirm_password') 6 7 if password and confirm_password and password != confirm_password: 8 # Attach error to a specific field 9 raise ValidationError({ 10 'confirm_password': 'Passwords do not match.' 11 }) 12 13 return cleaned_data
💡 Key difference:
clean_title()validates one field in isolation.clean()validates the entire form after all individual fields are valid.
💬 6. The Comment Form
Comments are simpler — just one field:
1# blog/forms.py 2 3class CommentForm(forms.ModelForm): 4 """ 5 Simple form for adding comments to blog posts. 6 """ 7 8 class Meta: 9 model = Comment 10 fields = ['content'] 11 widgets = { 12 'content': forms.Textarea(attrs={ 13 'rows': 3, 14 'placeholder': 'Write a thoughtful comment...', 15 'class': 'form-control' 16 }), 17 } 18 labels = { 19 'content': 'Your Comment' 20 }
🖥️ 7. Using Forms in Views
Function-Based View with Form
1# blog/views.py 2 3from django.shortcuts import render, redirect 4from django.contrib.auth.decorators import login_required 5from .forms import PostForm 6 7 8@login_required 9def create_post(request): 10 """ 11 Handle post creation via Function-Based View. 12 """ 13 if request.method == 'POST': 14 # Bind the form to POST data and any files 15 form = PostForm(request.POST, request.FILES) 16 17 if form.is_valid(): 18 # Save but don't commit yet (so we can set the author) 19 post = form.save(commit=False) 20 post.author = request.user 21 post.save() 22 23 # ManyToMany fields (tags) need the object to exist first 24 form.save_m2m() 25 26 return redirect('blog:post-detail', slug=post.slug) 27 else: 28 # Unbound form (empty form for GET request) 29 form = PostForm() 30 31 return render(request, 'blog/post_form.html', {'form': form})
What's happening:
request.POST: Contains the submitted text datarequest.FILES: Contains uploaded files (images, documents)form.is_valid(): Runs all validation stages. ReturnsTrueif everything passes.form.save(commit=False): Creates the model instance but doesn't save to DB yetform.save_m2m(): Saves ManyToMany relationships (like tags) after the main object exists
Class-Based View with Form
1# blog/views.py 2 3from django.views.generic import CreateView 4from django.contrib.auth.mixins import LoginRequiredMixin 5from .forms import PostForm 6 7 8class PostCreateView(LoginRequiredMixin, CreateView): 9 """ 10 Handle post creation via Class-Based View. 11 Uses PostForm automatically because we specified form_class. 12 """ 13 model = Post 14 form_class = PostForm 15 template_name = 'blog/post_form.html' 16 17 def form_valid(self, form): 18 """ 19 Called automatically when form passes validation. 20 """ 21 form.instance.author = self.request.user 22 return super().form_valid(form)
🧪 8. Practice Task — Contact Form with Honeypot
Task Requirements
Create a ContactForm that:
- Has fields:
name,email,subject,message - Validates that the email looks like a real email address
- Includes a honeypot field called
website(a hidden field that humans don't fill but bots do) - Rejects the form if the honeypot field is filled
- Validates that the message is at least 20 characters
Solution
1# blog/forms.py (or create a new contact/forms.py) 2 3from django import forms 4from django.core.exceptions import ValidationError 5from django.core.validators import EmailValidator 6 7 8class ContactForm(forms.Form): 9 """ 10 A standalone contact form (not tied to any model). 11 Includes honeypot spam protection. 12 """ 13 14 name = forms.CharField( 15 max_length=100, 16 widget=forms.TextInput(attrs={ 17 'class': 'form-control', 18 'placeholder': 'Your Name' 19 }) 20 ) 21 22 email = forms.EmailField( 23 widget=forms.EmailInput(attrs={ 24 'class': 'form-control', 25 'placeholder': 'your@email.com' 26 }) 27 ) 28 29 subject = forms.CharField( 30 max_length=200, 31 widget=forms.TextInput(attrs={ 32 'class': 'form-control', 33 'placeholder': 'Message Subject' 34 }) 35 ) 36 37 message = forms.CharField( 38 widget=forms.Textarea(attrs={ 39 'class': 'form-control', 40 'rows': 5, 41 'placeholder': 'Your message here...' 42 }) 43 ) 44 45 # HONEYPOT FIELD: Hidden from humans, visible to bots 46 # Should be rendered with CSS: display: none; 47 website = forms.CharField( 48 required=False, 49 widget=forms.HiddenInput(attrs={'style': 'display:none;'}), 50 initial='' 51 ) 52 53 def clean_website(self): 54 """ 55 Honeypot validation: if this field is filled, it's probably a bot. 56 """ 57 website = self.cleaned_data.get('website', '') 58 if website: 59 raise ValidationError('Spam detected.') 60 return website 61 62 def clean_message(self): 63 """ 64 Ensure the message has meaningful content. 65 """ 66 message = self.cleaned_data['message'] 67 if len(message) < 20: 68 raise ValidationError('Message must be at least 20 characters.') 69 return message 70 71 def clean(self): 72 """ 73 Additional form-level validation. 74 """ 75 cleaned_data = super().clean() 76 email = cleaned_data.get('email') 77 78 # Optional: check for disposable email domains 79 disposable_domains = ['tempmail.com', '10minutemail.com', 'mailinator.com'] 80 if email: 81 domain = email.split('@')[-1].lower() 82 if domain in disposable_domains: 83 raise ValidationError({ 84 'email': 'Please use a permanent email address.' 85 }) 86 87 return cleaned_data
The View for Contact Form
1# views.py 2 3from django.core.mail import send_mail 4from django.contrib import messages 5from .forms import ContactForm 6 7 8def contact_view(request): 9 if request.method == 'POST': 10 form = ContactForm(request.POST) 11 if form.is_valid(): 12 # Send email (configure EMAIL_BACKEND in settings.py first) 13 send_mail( 14 subject=f"Contact: {form.cleaned_data['subject']}", 15 message=form.cleaned_data['message'], 16 from_email=form.cleaned_data['email'], 17 recipient_list=['admin@yourdomain.com'], 18 ) 19 messages.success(request, 'Thank you! Your message has been sent.') 20 return redirect('blog:home') 21 else: 22 form = ContactForm() 23 24 return render(request, 'blog/contact.html', {'form': form})
The Template
1<!-- templates/blog/contact.html --> 2 3<form method="post" novalidate> 4 {% csrf_token %} 5 6 <!-- Honeypot field: hidden with CSS --> 7 <div style="display:none;"> 8 {{ form.website }} 9 </div> 10 11 {% for field in form.visible_fields %} 12 <div class="mb-3"> 13 <label for="{{ field.id_for_label }}">{{ field.label }}</label> 14 {{ field }} 15 {% if field.errors %} 16 <div class="text-danger"> 17 {{ field.errors }} 18 </div> 19 {% endif %} 20 </div> 21 {% endfor %} 22 23 <button type="submit" class="btn btn-primary">Send Message</button> 24</form>
🛡️ Why honeypots work: Spam bots fill in every field they find. Humans never see the hidden
websitefield. If it's filled, you know it's spam and can silently reject it without annoying real users with CAPTCHAs.
❌ Common Mistakes & How to Fix Them
| Mistake | Error / Symptom | Solution |
|---|---|---|
fields = '__all__' | Accidentally exposes sensitive fields | Always use explicit fields = [...] |
Missing request.FILES | Uploaded images don't save | Pass request.FILES to the form: MyForm(request.POST, request.FILES) |
Forgetting form.save_m2m() | Tags/categories not saved | Call it after commit=False save |
clean() without super().clean() | Missing cleaned data | Always call super().clean() first |
| Not returning cleaned value | Field becomes None | Every clean_<field>() must return value |
Missing {% csrf_token %} | 403 Forbidden on POST | Always include CSRF token in templates |
ValidationError as string in clean() | Error not attached to field | Use dict: raise ValidationError({'field': 'message'}) |
✅ Module 8 Summary
| Concept | Key Takeaway |
|---|---|
forms.Form | Standalone form, not tied to a model |
forms.ModelForm | Form automatically built from a model |
class Meta | Configuration: model, fields, widgets, labels |
widgets | Control HTML rendering and attributes |
clean_<field>() | Field-level custom validation |
clean() | Form-level validation across multiple fields |
is_valid() | Runs all validation stages |
form.save() | Saves model instance to database |
commit=False | Create instance without saving (to add extra data) |
save_m2m() | Save ManyToMany relations after main save |
ValidationError | Raise when data fails validation rules |
request.FILES | Required for file/image uploads |
🚪 What's Next?
In Module 9, we will dive into Django REST Framework — Serializers. You'll learn how to convert complex model data into JSON, validate API input, create nested serializers, and build serializers for user registration with password confirmation. This is the bridge between your Django models and modern frontend frameworks.
Before proceeding, make sure:
- You created
PostFormandCommentForminblog/forms.py - You understand the difference between
FormandModelForm - You can explain when to use
clean_title()vsclean() - Your contact form honeypot is working
- You know why
commit=Falseandsave_m2m()are needed - You always include
{% csrf_token %}in your templates
Your forms are bulletproof and spam-resistant. Ready to build REST APIs? 🌐🚀