Yesterday, I learned Models and ORM and had a working app with real data from a database. But all that data was created through the admin panel. Today, for Day 68, I learned about Django Forms, letting users submit data from the browser directly. By the end, I had a form on a real page that validates input, handles errors, and saves to the database.
What is a Django Form?
A Django Form is a Python class that handles three things:
- Rendering HTML input fields
- Validating submitted data
- Giving you clean, safe data to work with
You could write raw HTML forms and handle everything manually, but Django's form system saves a lot of work and handles security for you, including CSRF protection.
Two Types of Forms
Django has two kinds of forms:
-
Form— a standalone form, not tied to any model -
ModelForm— a form generated directly from a model
In most cases, you'll use ModelForm because you're usually creating or editing database records.
Creating a Form
Standalone Form
# core/forms.py
from django import forms
class ContactForm(forms.Form):
name = forms.CharField(max_length=100)
email = forms.EmailField()
message = forms.CharField(widget=forms.Textarea)
ModelForm
# core/forms.py
from django import forms
from .models import Post
class PostForm(forms.ModelForm):
class Meta:
model = Post
fields = ['title', 'content', 'is_published']
The Meta class tells Django which model to use and which fields to include. Django generates the form fields automatically based on the model field types.
Use fields = '__all__' to include every field, or explicitly list the fields you want. Always prefer listing fields explicitly; never expose fields you don't intend to.
Common Form Fields
| Field | Use |
|---|---|
CharField |
Short text input |
EmailField |
Email input with validation |
IntegerField |
Number input |
BooleanField |
Checkbox |
ChoiceField |
Dropdown select |
DateField |
Date input |
FileField |
File upload |
Widgets
A widget controls how a field is rendered as HTML. Every field has a default widget, but you can override it:
class PostForm(forms.ModelForm):
class Meta:
model = Post
fields = ['title', 'content', 'is_published']
widgets = {
'title': forms.TextInput(attrs={'placeholder': 'Enter title'}),
'content': forms.Textarea(attrs={'rows': 5}),
}
attrs lets you pass HTML attributes directly to the input element.
Handling Forms in Views
A form view handles two scenarios: a GET request (user visiting the page) and a POST request (user submitting the form).
# core/views.py
from django.shortcuts import render, redirect
from .forms import PostForm
def create_post(request):
if request.method == 'POST':
form = PostForm(request.POST)
if form.is_valid():
form.save()
return redirect('home')
else:
form = PostForm()
return render(request, 'core/create_post.html', {'form': form})
This pattern is the standard Django form view:
- If
POST— bind the submitted data to the form withPostForm(request.POST) - Validate with
is_valid()— runs all field validators and form-level validation - If valid — save and redirect
- If
GETor invalid — render the form (with errors if invalid)
The redirect() after a successful save prevents the form from being resubmitted if the user refreshes the page.
Rendering the Form in a Template
<!-- core/templates/core/create_post.html -->
{% extends 'core/base.html' %}
{% block content %}
<h1>Create Post</h1>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Save</button>
</form>
{% endblock %}
Three things to notice:
-
method="post"— forms that submit data must use POST -
{% csrf_token %}— required by Django for every POST form, protects against cross-site request forgery attacks. Django will reject the form without it. -
{{ form.as_p }}— renders all form fields wrapped in<p>tags
Other rendering options:
-
{{ form.as_p }}— fields in paragraph tags -
{{ form.as_ul }}— fields in list items -
{{ form.as_table }}— fields in table rows -
{{ form }}— same asas_table
Rendering Fields Manually
For more control over layout, render fields individually:
<form method="post">
{% csrf_token %}
<div>
<label for="{{ form.title.id_for_label }}">Title</label>
{{ form.title }}
{% if form.title.errors %}
<span>{{ form.title.errors }}</span>
{% endif %}
</div>
<div>
<label for="{{ form.content.id_for_label }}">Content</label>
{{ form.content }}
{% if form.content.errors %}
<span>{{ form.content.errors }}</span>
{% endif %}
</div>
<button type="submit">Save</button>
</form>
This gives you full control over the HTML structure while still using Django's field rendering and error handling.
Form Validation
is_valid() runs two levels of validation:
Field-level — built into each field type. EmailField checks it's a valid email, IntegerField checks it's a number, CharField checks max_length, and so on.
Custom validation — you can add your own rules:
class PostForm(forms.ModelForm):
class Meta:
model = Post
fields = ['title', 'content']
def clean_title(self):
title = self.cleaned_data['title']
if len(title) < 5:
raise forms.ValidationError("Title must be at least 5 characters.")
return title
def clean(self):
cleaned_data = super().clean()
title = cleaned_data.get('title')
content = cleaned_data.get('content')
if title and content and title == content:
raise forms.ValidationError("Title and content cannot be the same.")
return cleaned_data
-
clean_<fieldname>()— validates a single field -
clean()— validates across multiple fields at once
After is_valid() passes, access the validated data through form.cleaned_data:
if form.is_valid():
title = form.cleaned_data['title']
Editing an Existing Object
To edit rather than create, pass the existing instance to the form:
def edit_post(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('home')
else:
form = PostForm(instance=post)
return render(request, 'core/edit_post.html', {'form': form})
Passing instance=post pre-fills the form with the existing data and tells save() to update rather than create.
URL Setup
# core/urls.py
from django.urls import path
from . import views
urlpatterns = [
path('', views.home, name='home'),
path('post/<int:pk>/', views.post_detail, name='post_detail'),
path('post/create/', views.create_post, name='create_post'),
path('post/<int:pk>/edit/', views.edit_post, name='edit_post'),
]
The Full Flow
User visits /post/create/
↓
GET request → view creates empty form
↓
Template renders the form as HTML
↓
User fills in fields and submits
↓
POST request → view binds data to form
↓
form.is_valid() runs all validators
↓
If valid → form.save() → redirect
If invalid → re-render form with errors shown
Wrapping Up
Forms are where Django starts feeling like a real full-stack framework. The model defines the data structure, the form handles input and validation, the view orchestrates the flow, and the template renders it. All four layers working together.
Tomorrow: Django Authentication; user registration, login, and logout.
Thanks for reading. Feel free to share your thoughts!
Top comments (0)