Django Forms#
Complete guide to Django Forms and ModelForms for validation and data handling.
🎯 Basic Form#
Define Form#
# myapp/forms.py
from django import forms
class ContactForm(forms.Form):
name = forms.CharField(max_length=100, label='Your Name')
email = forms.EmailField(label='Your Email')
message = forms.CharField(
widget=forms.Textarea,
label='Message',
help_text='Enter your message here'
)
age = forms.IntegerField(min_value=0, max_value=120, required=False)
website = forms.URLField(required=False)
agree = forms.BooleanField(label='I agree to terms')
Use Form in View#
# myapp/views.py
from django.shortcuts import render, redirect
from django.contrib import messages
from .forms import ContactForm
def contact_view(request):
if request.method == 'POST':
form = ContactForm(request.POST)
if form.is_valid():
# Access cleaned data
name = form.cleaned_data['name']
email = form.cleaned_data['email']
message = form.cleaned_data['message']
# Process form data
# Send email, save to database, etc.
messages.success(request, 'Message sent!')
return redirect('myapp:contact')
else:
form = ContactForm()
return render(request, 'myapp/contact.html', {'form': form})
Render Form in Template#
<!-- myapp/templates/myapp/contact.html -->
{% extends 'myapp/base.html' %}
{% block content %}
<h1>Contact Us</h1>
<form method="post">
{% csrf_token %}
<!-- Render entire form -->
{{ form.as_p }}
<!-- Or render fields individually -->
<div>
{{ form.name.label_tag }}
{{ form.name }}
{{ form.name.errors }}
{% if form.name.help_text %}
<small>{{ form.name.help_text }}</small>
{% endif %}
</div>
<div>
{{ form.email.label_tag }}
{{ form.email }}
{{ form.email.errors }}
</div>
<div>
{{ form.message.label_tag }}
{{ form.message }}
{{ form.message.errors }}
</div>
<button type="submit">Send</button>
</form>
{% endblock %}
📋 Form Rendering Options#
<!-- Render as paragraphs -->
{{ form.as_p }}
<!-- Render as table -->
{{ form.as_table }}
<!-- Render as list -->
{{ form.as_ul }}
<!-- Render fields individually -->
{{ form.name }}
{{ form.email }}
{{ form.message }}
🎨 Form Fields#
Text Fields#
# CharField
name = forms.CharField(
max_length=100,
min_length=2,
required=True,
label='Name',
help_text='Enter your full name',
initial='John Doe',
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Enter name'})
)
# TextField (Textarea)
message = forms.CharField(
widget=forms.Textarea(attrs={'rows': 5, 'cols': 40, 'class': 'form-control'}),
max_length=1000
)
# EmailField
email = forms.EmailField()
# URLField
website = forms.URLField()
# SlugField
slug = forms.SlugField()
Numeric Fields#
# IntegerField
age = forms.IntegerField(min_value=0, max_value=120)
# DecimalField
price = forms.DecimalField(max_digits=10, decimal_places=2)
# FloatField
rating = forms.FloatField(min_value=0.0, max_value=5.0)
Choice Fields#
# ChoiceField
STATUS_CHOICES = [
('draft', 'Draft'),
('published', 'Published'),
]
status = forms.ChoiceField(choices=STATUS_CHOICES)
# RadioSelect widget
status = forms.ChoiceField(
choices=STATUS_CHOICES,
widget=forms.RadioSelect
)
# TypedChoiceField (with type coercion)
priority = forms.TypedChoiceField(
choices=[(1, 'Low'), (2, 'Medium'), (3, 'High')],
coerce=int
)
Date/Time Fields#
# DateField
birth_date = forms.DateField(
widget=forms.DateInput(attrs={'type': 'date'})
)
# DateTimeField
event_date = forms.DateTimeField(
widget=forms.DateTimeInput(attrs={'type': 'datetime-local'})
)
# TimeField
start_time = forms.TimeField(
widget=forms.TimeInput(attrs={'type': 'time'})
)
File Fields#
# FileField
document = forms.FileField()
# ImageField (requires Pillow)
photo = forms.ImageField()
# Multiple files
files = forms.FileField(widget=forms.ClearableFileInput(attrs={'multiple': True}))
Boolean Fields#
# BooleanField
agree = forms.BooleanField(label='I agree to terms')
# NullBooleanField
is_featured = forms.NullBooleanField()
🎯 ModelForm#
Basic ModelForm#
# myapp/forms.py
from django import forms
from .models import Post
class PostForm(forms.ModelForm):
class Meta:
model = Post
fields = ['title', 'content', 'author', 'category', 'tags', 'is_published']
# Or exclude specific fields
# exclude = ['slug', 'views', 'created_at']
# Customize widgets
widgets = {
'content': forms.Textarea(attrs={'rows': 10, 'class': 'form-control'}),
'published_at': forms.DateTimeInput(attrs={'type': 'datetime-local'}),
}
# Customize labels
labels = {
'title': 'Post Title',
'content': 'Post Content',
}
# Customize help text
help_texts = {
'title': 'Enter a descriptive title',
'content': 'Write your post content here',
}
Use ModelForm in View#
# myapp/views.py
from django.shortcuts import render, redirect, get_object_or_404
from .forms import PostForm
from .models import Post
def post_create(request):
if request.method == 'POST':
form = PostForm(request.POST)
if form.is_valid():
post = form.save(commit=False)
post.author = request.user
post.save()
form.save_m2m() # Save ManyToMany fields
return redirect('myapp:post_detail', pk=post.pk)
else:
form = PostForm()
return render(request, 'myapp/post_form.html', {'form': form})
def post_update(request, pk):
post = get_object_or_404(Post, pk=pk)
if request.method == 'POST':
form = PostForm(request.POST, instance=post)
if form.is_valid():
form.save()
return redirect('myapp:post_detail', pk=post.pk)
else:
form = PostForm(instance=post)
return render(request, 'myapp/post_form.html', {'form': form, 'post': post})
✅ Custom Validation#
Field-Level Validation#
# myapp/forms.py
from django import forms
from django.core.exceptions import ValidationError
class PostForm(forms.ModelForm):
title = forms.CharField(max_length=200)
def clean_title(self):
title = self.cleaned_data.get('title')
if title and len(title) < 10:
raise ValidationError('Title must be at least 10 characters long.')
return title
class Meta:
model = Post
fields = ['title', 'content']
Form-Level Validation#
class PostForm(forms.ModelForm):
class Meta:
model = Post
fields = ['title', 'content', 'excerpt']
def clean(self):
cleaned_data = super().clean()
title = cleaned_data.get('title')
content = cleaned_data.get('content')
# Cross-field validation
if title and content:
if title.lower() in content.lower():
raise ValidationError('Title should not be repeated in content.')
return cleaned_data
Custom Validators#
# myapp/validators.py
from django.core.exceptions import ValidationError
def validate_no_profanity(value):
profanity_words = ['bad', 'word'] # Example
if any(word in value.lower() for word in profanity_words):
raise ValidationError('Please avoid inappropriate language.')
# Use in form
class PostForm(forms.ModelForm):
content = forms.CharField(validators=[validate_no_profanity])
class Meta:
model = Post
fields = ['title', 'content']
🎨 Custom Widgets#
Custom Widget#
# myapp/widgets.py
from django import forms
class CustomTextarea(forms.Textarea):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.attrs.update({'class': 'custom-textarea', 'rows': 10})
# Use in form
class PostForm(forms.ModelForm):
content = forms.CharField(widget=CustomTextarea())
class Meta:
model = Post
fields = ['title', 'content']
📝 FormSets#
ModelFormSet#
# myapp/forms.py
from django.forms import modelformset_factory
from .models import Post
PostFormSet = modelformset_factory(
Post,
fields=['title', 'content'],
extra=2, # Number of empty forms
can_delete=True # Allow deletion
)
# In view
def manage_posts(request):
if request.method == 'POST':
formset = PostFormSet(request.POST)
if formset.is_valid():
formset.save()
return redirect('myapp:post_list')
else:
formset = PostFormSet(queryset=Post.objects.filter(author=request.user))
return render(request, 'myapp/manage_posts.html', {'formset': formset})
<!-- myapp/templates/myapp/manage_posts.html -->
<form method="post">
{% csrf_token %}
{{ formset.management_form }}
{% for form in formset %}
<div class="form-row">
{{ form.id }}
{{ form.title }}
{{ form.content }}
{% if form.DELETE %}
{{ form.DELETE }} Delete
{% endif %}
</div>
{% endfor %}
<button type="submit">Save</button>
</form>
🎯 Inline Formsets#
# myapp/forms.py
from django.forms import inlineformset_factory
from .models import Post, Comment
CommentFormSet = inlineformset_factory(
Post,
Comment,
fields=['author', 'content'],
extra=1,
can_delete=True
)
# In view
def edit_post_with_comments(request, pk):
post = get_object_or_404(Post, pk=pk)
if request.method == 'POST':
form = PostForm(request.POST, instance=post)
formset = CommentFormSet(request.POST, instance=post)
if form.is_valid() and formset.is_valid():
form.save()
formset.save()
return redirect('myapp:post_detail', pk=post.pk)
else:
form = PostForm(instance=post)
formset = CommentFormSet(instance=post)
return render(request, 'myapp/edit_post.html', {
'form': form,
'formset': formset
})
✅ Best Practices#
- ✅ Use ModelForm - For model-based forms
- ✅ Validate in forms - Not just in views
- ✅ Use cleaned_data - Always use cleaned_data, not request.POST
- ✅ Customize widgets - Better UX with proper widgets
- ✅ Handle errors - Display form errors in templates
- ✅ Use commit=False - When you need to set additional fields
- ✅ Save ManyToMany separately - Use form.save_m2m() after commit=False
✅ Complete Example#
# myapp/forms.py
from django import forms
from django.core.exceptions import ValidationError
from .models import Post, Category
class PostForm(forms.ModelForm):
class Meta:
model = Post
fields = ['title', 'slug', 'content', 'excerpt', 'category', 'tags',
'is_published', 'published_at']
widgets = {
'content': forms.Textarea(attrs={'rows': 15, 'class': 'form-control'}),
'excerpt': forms.Textarea(attrs={'rows': 3, 'class': 'form-control'}),
'published_at': forms.DateTimeInput(attrs={'type': 'datetime-local'}),
'tags': forms.CheckboxSelectMultiple,
}
labels = {
'title': 'Post Title',
'slug': 'URL Slug',
}
help_texts = {
'slug': 'URL-friendly version of title',
}
def clean_title(self):
title = self.cleaned_data.get('title')
if title and len(title) < 10:
raise ValidationError('Title must be at least 10 characters.')
return title
def clean(self):
cleaned_data = super().clean()
is_published = cleaned_data.get('is_published')
published_at = cleaned_data.get('published_at')
if is_published and not published_at:
from django.utils import timezone
cleaned_data['published_at'] = timezone.now()
return cleaned_data
✅ Next Steps#
- Learn Views, URLs & Templates for form handling
- Learn CRUD Operations for create/update patterns
- Learn Admin Customization for admin forms
Pro Tip: Always use form.cleaned_data instead of request.POST - it's validated and safe. Use commit=False when you need to set additional fields before saving!