Validation first
Form fields check data types and rules before the data reaches your database.
Django Forms are the safest and cleanest way to handle user input.
They solve three problems at the same time:
Validation first
Form fields check data types and rules before the data reaches your database.
Cleaner views
Views stay focused on handling the request, while forms take care of parsing and validation.
Security built in
Forms work naturally with CSRF protection and safe output rendering.
Reusable logic
Validation rules live in one place and can be reused across different views.
Always follow this pattern:
request.POST (and request.FILES if needed).form.is_valid().cleaned_data and redirect.from django.shortcuts import redirect, renderfrom .forms import ContactForm
def contact_view(request): if request.method == "POST": form = ContactForm(request.POST) if form.is_valid(): # process form.cleaned_data return redirect("contact-success") else: form = ContactForm()
return render(request, "contact.html", {"form": form})Use forms.Form when the input does not match a model directly, field by field.
from django import forms
class ContactForm(forms.Form): name = forms.CharField(max_length=120) email = forms.EmailField() subject = forms.CharField(max_length=200) message = forms.CharField(widget=forms.Textarea) subscribe = forms.BooleanField(required=False)Important field options:
requiredinitiallabelhelp_textvalidatorswidgetname = forms.CharField( max_length=120, required=True, label="Full name", help_text="Use first and last name",)form.is_valid() runs through these steps:
clean_<field>() methods.clean() for checks that involve more than one field.cleaned_data.Raw request.POST values are just strings. cleaned_data gives you the parsed, proper Python values instead.
from django import formsfrom django.core.exceptions import ValidationError
class ContactForm(forms.Form): name = forms.CharField(max_length=120) email = forms.EmailField()
def clean_name(self): value = self.cleaned_data["name"].strip() if len(value.split()) < 2: raise ValidationError("Please enter first and last name.") return value
def clean_email(self): value = self.cleaned_data["email"].lower() if value.endswith("@temporarymail.com"): raise ValidationError("Temporary email addresses are not allowed.") return valueRules:
clean_<fieldname>ValidationError when something is invalidfrom django import forms
class BookingForm(forms.Form): start_date = forms.DateField() end_date = forms.DateField() guest_count = forms.IntegerField(min_value=1)
def clean(self): cleaned_data = super().clean() start_date = cleaned_data.get("start_date") end_date = cleaned_data.get("end_date")
if start_date and end_date and end_date < start_date: self.add_error("end_date", "End date must be after start date.")
return cleaned_dataUse raise ValidationError(...) inside clean() when the error applies to the whole form, not just one field (this shows up in non_field_errors).
Always include the CSRF token inside POST forms.
<form method="post" novalidate> {% csrf_token %} {{ form.as_p }} <button type="submit">Send</button></form>novalidate is useful while you are learning, because it turns off the browser’s own validation pop-ups, which makes it easier to see Django’s own error messages.
{{ form.as_p }} wraps each field inside a paragraph.
{{ form.as_ul }} shows the fields as list items.
{{ form.as_table }} shows the fields as table rows.
<form method="post" novalidate> {% csrf_token %}
{% if form.non_field_errors %} <div class="form-errors"> {{ form.non_field_errors }} </div> {% endif %}
<div> {{ form.name.label_tag }} {{ form.name }} {% if form.name.help_text %} <small>{{ form.name.help_text }}</small> {% endif %} {{ form.name.errors }} </div>
<div> {{ form.email.label_tag }} {{ form.email }} {{ form.email.errors }} </div>
<button type="submit">Submit</button></form>What each part means:
form.field.label_tag: the HTML label connected to the inputform.field: the field’s input HTMLform.field.errors: error list for that specific fieldform.non_field_errors: whole-form errors coming from clean()form.field.help_text: an optional helper messageUse ModelForm when the form fields map directly to a model.
# models.pyfrom django.db import models
class Article(models.Model): title = models.CharField(max_length=200) body = models.TextField() published = models.BooleanField(default=False)
def __str__(self): return self.title# forms.pyfrom django import formsfrom django.core.exceptions import ValidationErrorfrom .models import Article
class ArticleForm(forms.ModelForm): class Meta: model = Article fields = ["title", "body", "published"] labels = { "title": "Article title", } help_texts = { "title": "Keep the title clear and specific.", } widgets = { "body": forms.Textarea(attrs={"rows": 6}), }
def clean_title(self): title = self.cleaned_data["title"].strip() if len(title) < 10: raise ValidationError("Title must be at least 10 characters.") return titlecommit=Falseform = ArticleForm(request.POST)if form.is_valid(): article = form.save() # create or updateif form.is_valid(): article = form.save(commit=False) article.author = request.user article.save()Use commit=False when you need to set extra fields that are not part of the form itself.
# views.pyfrom django.shortcuts import get_object_or_404, redirect, renderfrom .forms import ArticleFormfrom .models import Article
def article_create(request): if request.method == "POST": form = ArticleForm(request.POST) if form.is_valid(): form.save() return redirect("article-list") else: form = ArticleForm()
return render(request, "articles/form.html", {"form": form, "mode": "create"})
def article_update(request, pk): article = get_object_or_404(Article, pk=pk)
if request.method == "POST": form = ArticleForm(request.POST, instance=article) if form.is_valid(): form.save() return redirect("article-detail", pk=article.pk) else: form = ArticleForm(instance=article)
return render(request, "articles/form.html", {"form": form, "mode": "update"})<!-- templates/articles/form.html --><h1>{% if mode == "create" %}Create Article{% else %}Update Article{% endif %}</h1>
<form method="post" novalidate> {% csrf_token %}
{{ form.non_field_errors }}
<div> {{ form.title.label_tag }} {{ form.title }} {{ form.title.errors }} </div>
<div> {{ form.body.label_tag }} {{ form.body }} {{ form.body.errors }} </div>
<div> {{ form.published }} {{ form.published.label_tag }} {{ form.published.errors }} </div>
<button type="submit">{% if mode == "create" %}Create{% else %}Update{% endif %}</button></form>For file uploads, pass request.FILES and set enctype="multipart/form-data".
class DocumentForm(forms.Form): title = forms.CharField(max_length=120) file = forms.FileField()def upload_document(request): if request.method == "POST": form = DocumentForm(request.POST, request.FILES) if form.is_valid(): uploaded_file = form.cleaned_data["file"] # save file return redirect("upload-success") else: form = DocumentForm()
return render(request, "upload.html", {"form": form})<form method="post" enctype="multipart/form-data"> {% csrf_token %} {{ form.as_p }} <button type="submit">Upload</button></form>This section shows a simple and practical way to set up authentication for real projects.
It covers login, signup, logout, settings, protecting pages, and protecting pages based on user roles.
# project/settings.pyINSTALLED_APPS = [ # ... "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles",]
MIDDLEWARE = [ # ... "django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware",]
# Required only when you define a custom user modelAUTH_USER_MODEL = "appname.User"
# Where to send user when login is requiredLOGIN_URL = "login"
# Where to send user after successful loginLOGIN_REDIRECT_URL = "dashboard"
# Where to send user after logoutLOGOUT_REDIRECT_URL = "login"
# Production-safe cookies (turn on when HTTPS is enabled)SESSION_COOKIE_SECURE = TrueCSRF_COOKIE_SECURE = TrueSESSION_COOKIE_HTTPONLY = TrueFor most projects, Django’s default user model is good enough. It already includes username, email, password, and more.
from django.contrib.auth.models import UserIf you need a custom user model, inherit from AbstractUser and set AUTH_USER_MODEL in your settings before your first migration.
from django.contrib.auth.models import AbstractUser
class User(AbstractUser): # Add custom fields here passWhen writing queries inside forms or views, prefer using get_user_model() so your code works correctly with both the default user model and a custom one.
Keep your auth form logic inside forms.py so your views stay clean and easy to read.
# forms.pyfrom django import formsfrom django.contrib.auth import get_user_modelfrom django.core.exceptions import ValidationError
User = get_user_model()
class LoginForm(forms.Form): username = forms.CharField(max_length=150) password = forms.CharField(widget=forms.PasswordInput)
class SignupForm(forms.Form): username = forms.CharField(max_length=150) email = forms.EmailField() password1 = forms.CharField(widget=forms.PasswordInput) password2 = forms.CharField(widget=forms.PasswordInput)
def clean_username(self): username = self.cleaned_data["username"].strip() if User.objects.filter(username=username).exists(): raise ValidationError("Username already exists.") return username
def clean_email(self): email = self.cleaned_data["email"].strip().lower() if User.objects.filter(email=email).exists(): raise ValidationError("Email already exists.") return email
def clean(self): cleaned_data = super().clean() password1 = cleaned_data.get("password1") password2 = cleaned_data.get("password2")
if password1 and password2 and password1 != password2: raise ValidationError("Passwords do not match.")
return cleaned_dataUse LoginForm to read and check the data, then call authenticate().
# views.pyfrom django.contrib.auth import authenticate, loginfrom django.shortcuts import redirect, renderfrom .forms import LoginForm
def login_view(request): if request.method == "POST": form = LoginForm(request.POST) if form.is_valid(): username = form.cleaned_data["username"] password = form.cleaned_data["password"]
user = authenticate(request, username=username, password=password) if user is not None: login(request, user) return redirect("dashboard")
form.add_error(None, "Invalid username or password.") else: form = LoginForm()
return render(request, "auth/login.html", {"form": form})Use SignupForm together with create_user() so that validation and password handling both stay safe.
# views.pyfrom django.contrib import messagesfrom django.contrib.auth import get_user_model, loginfrom django.shortcuts import redirect, renderfrom .forms import SignupForm
User = get_user_model()
def signup_view(request): if request.method == "POST": form = SignupForm(request.POST) if form.is_valid(): user = User.objects.create_user( username=form.cleaned_data["username"], email=form.cleaned_data["email"], password=form.cleaned_data["password1"], ) login(request, user) messages.success(request, "Account created successfully.") return redirect("dashboard") else: form = SignupForm()
return render(request, "auth/signup.html", {"form": form})logout() clears the current session and logs the user out.
# views.pyfrom django.contrib.auth import logoutfrom django.shortcuts import redirect
def logout_view(request): logout(request) return redirect("login")# app/urls.pyfrom django.urls import pathfrom . import views
urlpatterns = [ path("login/", views.login_view, name="login"), path("signup/", views.signup_view, name="signup"), path("logout/", views.logout_view, name="logout"), path("dashboard/", views.dashboard_view, name="dashboard"),]You can use Django’s built-in decorators, or create your own, to protect views. These decorators check if the user is logged in and has the right permissions before letting them access the view.
@login_required decoratorUse @login_required for any page that should not be open to everyone.
# views.pyfrom django.contrib.auth.decorators import login_requiredfrom django.shortcuts import render
@login_requireddef dashboard_view(request): return render(request, "dashboard.html")Use this when only admin users should be able to open a view.
# decorators.pyfrom functools import wrapsfrom django.http import HttpResponseForbidden
def admin_required(view_func): @wraps(view_func) def _wrapped_view(request, *args, **kwargs): if not request.user.is_authenticated: from django.shortcuts import redirect
return redirect("login")
if not request.user.is_staff: return HttpResponseForbidden("You do not have permission to open this page.")
return view_func(request, *args, **kwargs)
return _wrapped_view# views.pyfrom django.shortcuts import renderfrom .decorators import admin_required
@admin_requireddef admin_reports_view(request): return render(request, "admin/reports.html")Groups and permissions are the core of Django’s authorization system. They let you control who is allowed to do what in your app. They help you set up role-based access control (RBAC) in a clean and scalable way.
Think of it like this:
view_order)Customer Service)auth_permission table in the database after running migrations. (You need to look for the table name auth_permission in the database, not the model name Permission.)Groups help you bundle permissions together into roles. Instead of giving permissions to each user one by one, you give them to a group, and then add users to that group. This becomes much easier to manage as your number of users grows. A user can also belong to more than one group, which gives you a lot of flexibility when managing permissions.
Permissions are the specific actions that users are allowed to perform. They are defined inside the model’s Meta class, and can either be the default ones (add, change, delete, view) or custom ones (like cancel_order). Permissions are stored in the database and linked to models using content types.
You can assign permissions to individual users, but it is usually better to assign them to groups and then add users to those groups instead. This way, you can manage permissions at the group level, which is more efficient and easier to maintain.
When you create a model and run migrations, Django automatically creates default permissions for that model:
addchangedeleteviewExample for a Customer model:
add_customerchange_customerdelete_customerview_customerThese get stored in the database (auth_permission) and are linked to models through Django’s content types.
Use this flow in admin:
Admin -> Authentication and Authorization -> Groups.Add group.Customer Service.Then assign users to that group:
Admin -> Users.Staff status if that user needs admin access.The default permissions are not always enough.
Example: “cancel order” is a business action, not a basic CRUD action.
You can define custom permissions inside the model’s Meta class:
# models.pyfrom django.db import models
class Order(models.Model): # fields...
class Meta: permissions = [ ("cancel_order", "Can cancel order"), ]Then run migrations:
python manage.py makemigrationspython manage.py migrateAfter the migration, these permissions will show up in admin and can be assigned to groups or users.
Use Django’s built-in decorators for simple checks.
from django.contrib.auth.decorators import login_required, permission_requiredfrom django.shortcuts import render
@login_required@permission_required("store.view_order", raise_exception=True)def orders_list(request): return render(request, "orders/list.html")Use raise_exception=True when you want a 403 Forbidden error instead of a redirect.