Validation first
Form fields enforce data types and rules before data reaches your database.
Django Forms are the safest and cleanest way to handle user input.
They solve three problems at once:
Validation first
Form fields enforce data types and rules before data reaches your database.
Cleaner views
Views stay focused on request flow while forms own parsing and validation.
Security built in
Forms work naturally with CSRF and safe output rendering.
Reusable logic
Validation rules live in one place and can be reused across 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 input is not a direct one-to-one model mapping.
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 this pipeline:
clean_<field>() methods.clean() for cross-field validation.cleaned_data.Raw request.POST values are strings. cleaned_data gives parsed Python values.
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> exactlyValidationError when 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(...) in clean() when the error is form-wide (shows in non_field_errors).
Always include CSRF token in POST forms.
<form method="post" novalidate> {% csrf_token %} {{ form.as_p }} <button type="submit">Send</button></form>novalidate is useful while learning because browser validation UI is disabled and Django errors are easier to inspect.
{{ form.as_p }} wraps each field in a paragraph.
{{ form.as_ul }} renders fields as list items.
{{ form.as_table }} renders 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: HTML label linked to the inputform.field: field widget HTMLform.field.errors: field-specific error listform.non_field_errors: form-wide errors from clean()form.field.help_text: optional helper messageUse ModelForm when 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 must set extra fields not exposed in the form.
# 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 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 clean and practical auth setup for real projects.
It covers login, signup, logout, settings, page protection, and role-based protection.
# 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 enough. It 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 settings before your first migration.
from django.contrib.auth.models import AbstractUser
class User(AbstractUser): # Add custom fields here passWhen writing queries in forms/views, prefer get_user_model() so your code works with both default and custom user models.
Keep auth form logic in forms.py so 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 parse and validate 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 and create_user() so validation and password handling 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 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 built-in decorators or create custom ones to protect views. These decorators check if the user is authenticated and has the right permissions before allowing access to the view.
@login_required decoratorUse @login_required for any page that should not be public.
# 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 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 allow you to control who can do what in your application. It helps you implement role-based access control (RBAC) in a clean and scalable way.
Think like this:
view_order)Customer Service)auth_permission table in the database after running migrations. (You have to see the table name auth_permission in the database, not the model name Permission.)Groups help to bundle permissions into roles. Instead of assigning permissions to each user, you assign them to groups and then add users to those groups. This is much easier to manage as your user base grows. You can also have users in multiple groups, which gives you flexibility in permission management.
Permissions are the specific actions that users can perform. They are defined in the model’s Meta class and can be either default (add, change, delete, view) or custom (like cancel_order). Permissions are stored in the database and linked to models via content types.
You can individually assign permissions to users, but it’s usually better to assign them to groups and then add users to those groups. 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 are stored in the database (auth_permission) and linked to models via Django 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 the user needs admin access.Default permissions are not always enough.
Example: “cancel order” is a business action, not a basic CRUD action.
Define custom permissions in model Meta:
# 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 migration, these permissions appear in admin and can be assigned to groups/users.
Use 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 403 Forbidden instead of redirect.