The Form That Cost Me 3 Hours of Debugging
I deployed a user registration form to production. Within hours, the database filled with garbage: usernames like "admin123!!!!", emails missing the @ symbol, and passwords that were literally "password". My form validation was broken, and I didn't even know Django Forms could have prevented all of this.
That painful lesson taught me everything about Django Forms. Today, I'll show you how to build bulletproof forms with validation, multi-file uploads with previews, proper error handling, and debugging techniques that will save you hours of frustration.
What Are Django Forms and Why Should You Care?
Django Forms are Python classes that handle:
- ✅ HTML form generation (no manual
<input>tags) - ✅ Data validation (automatic type checking, custom rules)
- ✅ Error handling (field-level and form-level errors)
- ✅ Security (CSRF protection, XSS prevention)
- ✅ File uploads (with size limits, type validation)
- ✅ Database integration (ModelForms sync with models)
Without Django Forms:
<!-- Manual HTML - prone to errors -->
<form method="post">
<input type="text" name="username">
<input type="email" name="email">
<input type="password" name="password">
<button type="submit">Register</button>
</form>
# Manual validation - nightmare to maintain
def register(request):
if request.method == 'POST':
username = request.POST.get('username')
email = request.POST.get('email')
password = request.POST.get('password')
# Manually check everything
if not username or len(username) < 3:
errors.append("Username too short")
if '@' not in email:
errors.append("Invalid email")
if len(password) < 8:
errors.append("Password too short")
# ... 50 more lines of validation
With Django Forms:
from django import forms
class RegistrationForm(forms.Form):
username = forms.CharField(min_length=3, max_length=20)
email = forms.EmailField()
password = forms.CharField(min_length=8, widget=forms.PasswordInput())
That's it. Django handles validation, errors, HTML generation, and security automatically.
Part 1: Basic Django Forms - Foundation
Creating Your First Form
# forms.py
from django import forms
class ContactForm(forms.Form):
name = forms.CharField(
max_length=100,
required=True,
widget=forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Enter your name'
})
)
email = forms.EmailField(
required=True,
widget=forms.EmailInput(attrs={
'class': 'form-control',
'placeholder': 'your@email.com'
})
)
subject = forms.CharField(
max_length=200,
widget=forms.TextInput(attrs={'class': 'form-control'})
)
message = forms.CharField(
widget=forms.Textarea(attrs={
'class': 'form-control',
'rows': 5,
'placeholder': 'Your message here...'
})
)
# Custom validation for specific field
def clean_name(self):
name = self.cleaned_data.get('name')
if any(char.isdigit() for char in name):
raise forms.ValidationError("Name cannot contain numbers")
return name.title() # Capitalize properly
# Form-wide validation
def clean(self):
cleaned_data = super().clean()
email = cleaned_data.get('email')
# Check if email domain is blacklisted
if email and email.endswith('@spam.com'):
raise forms.ValidationError("This email domain is not allowed")
return cleaned_data
Using the Form in Views
# 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 validated data
name = form.cleaned_data['name']
email = form.cleaned_data['email']
subject = form.cleaned_data['subject']
message = form.cleaned_data['message']
# Process the data (send email, save to DB, etc.)
# send_contact_email(name, email, subject, message)
messages.success(request, 'Your message has been sent!')
return redirect('contact')
else:
# Form has errors - will be displayed in template
messages.error(request, 'Please correct the errors below.')
else:
form = ContactForm()
return render(request, 'contact.html', {'form': form})
Template Rendering
<!-- contact.html -->
{% load static %}
<div class="container mt-5">
<h2>Contact Us</h2>
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }}">
{{ message }}
</div>
{% endfor %}
{% endif %}
<form method="post" novalidate>
{% csrf_token %}
<!-- Option 1: Automatic rendering (quick & easy) -->
{{ form.as_p }}
<!-- Option 2: Manual control (recommended for production) -->
<div class="form-group">
<label for="{{ form.name.id_for_label }}">Name</label>
{{ form.name }}
{% if form.name.errors %}
<div class="text-danger">
{% for error in form.name.errors %}
<small>{{ error }}</small>
{% endfor %}
</div>
{% endif %}
</div>
<div class="form-group">
<label for="{{ form.email.id_for_label }}">Email</label>
{{ form.email }}
{% if form.email.errors %}
<div class="text-danger">
{% for error in form.email.errors %}
<small>{{ error }}</small>
{% endfor %}
</div>
{% endif %}
</div>
<div class="form-group">
<label for="{{ form.subject.id_for_label }}">Subject</label>
{{ form.subject }}
{{ form.subject.errors }}
</div>
<div class="form-group">
<label for="{{ form.message.id_for_label }}">Message</label>
{{ form.message }}
{{ form.message.errors }}
</div>
<!-- Display non-field errors (form-wide errors) -->
{% if form.non_field_errors %}
<div class="alert alert-danger">
{{ form.non_field_errors }}
</div>
{% endif %}
<button type="submit" class="btn btn-primary">Send Message</button>
</form>
</div>
Part 2: Advanced Validation - Real-World Examples
Problem: User Registration with Complex Rules
Requirements:
- Username: 3-20 chars, alphanumeric only, not already taken
- Email: Valid format, not already registered
- Password: 8+ chars, must contain uppercase, lowercase, number
- Confirm password: Must match password
# forms.py
from django import forms
from django.contrib.auth.models import User
import re
class UserRegistrationForm(forms.Form):
username = forms.CharField(
min_length=3,
max_length=20,
widget=forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Choose a username'
})
)
email = forms.EmailField(
widget=forms.EmailInput(attrs={
'class': 'form-control',
'placeholder': 'your@email.com'
})
)
password = forms.CharField(
min_length=8,
widget=forms.PasswordInput(attrs={
'class': 'form-control',
'placeholder': 'Strong password'
})
)
confirm_password = forms.CharField(
widget=forms.PasswordInput(attrs={
'class': 'form-control',
'placeholder': 'Confirm password'
})
)
def clean_username(self):
"""Validate username uniqueness and format"""
username = self.cleaned_data.get('username')
# Check alphanumeric only
if not username.isalnum():
raise forms.ValidationError(
"Username must contain only letters and numbers"
)
# Check if username already exists
if User.objects.filter(username__iexact=username).exists():
raise forms.ValidationError(
"This username is already taken"
)
return username.lower()
def clean_email(self):
"""Validate email uniqueness"""
email = self.cleaned_data.get('email')
if User.objects.filter(email__iexact=email).exists():
raise forms.ValidationError(
"An account with this email already exists"
)
return email.lower()
def clean_password(self):
"""Validate password strength"""
password = self.cleaned_data.get('password')
# Check for uppercase letter
if not any(char.isupper() for char in password):
raise forms.ValidationError(
"Password must contain at least one uppercase letter"
)
# Check for lowercase letter
if not any(char.islower() for char in password):
raise forms.ValidationError(
"Password must contain at least one lowercase letter"
)
# Check for digit
if not any(char.isdigit() for char in password):
raise forms.ValidationError(
"Password must contain at least one number"
)
# Check for common passwords
common_passwords = ['password', '12345678', 'qwerty123']
if password.lower() in common_passwords:
raise forms.ValidationError(
"This password is too common"
)
return password
def clean(self):
"""Validate form-wide rules"""
cleaned_data = super().clean()
password = cleaned_data.get('password')
confirm_password = cleaned_data.get('confirm_password')
# Check if passwords match
if password and confirm_password:
if password != confirm_password:
raise forms.ValidationError(
"Passwords do not match"
)
return cleaned_data
Real-Time Frontend Validation (Bonus)
Add JavaScript for better UX:
<script>
document.addEventListener('DOMContentLoaded', function() {
const passwordInput = document.querySelector('input[name="password"]');
const confirmInput = document.querySelector('input[name="confirm_password"]');
passwordInput.addEventListener('input', function() {
const password = this.value;
const strength = document.getElementById('password-strength');
let score = 0;
if (password.length >= 8) score++;
if (/[A-Z]/.test(password)) score++;
if (/[a-z]/.test(password)) score++;
if (/[0-9]/.test(password)) score++;
if (/[^A-Za-z0-9]/.test(password)) score++;
if (score < 3) {
strength.textContent = 'Weak';
strength.className = 'text-danger';
} else if (score < 4) {
strength.textContent = 'Medium';
strength.className = 'text-warning';
} else {
strength.textContent = 'Strong';
strength.className = 'text-success';
}
});
confirmInput.addEventListener('input', function() {
if (this.value !== passwordInput.value) {
this.setCustomValidity('Passwords do not match');
} else {
this.setCustomValidity('');
}
});
});
</script>
Part 3: File Uploads - Single and Multiple Files
Problem: Job Application Form with Resume and Portfolio
Requirements:
- Resume: PDF/DOC only, max 5MB
- Portfolio: Multiple images (PNG/JPG), each max 2MB
- Cover letter: Optional PDF
# forms.py
from django import forms
from django.core.validators import FileExtensionValidator
from django.core.exceptions import ValidationError
def validate_file_size(file, max_size_mb=5):
"""Custom validator for file size"""
max_size = max_size_mb * 1024 * 1024 # Convert MB to bytes
if file.size > max_size:
raise ValidationError(
f'File size cannot exceed {max_size_mb}MB. Current size: {file.size / (1024*1024):.2f}MB'
)
class JobApplicationForm(forms.Form):
# Basic fields
full_name = forms.CharField(
max_length=100,
widget=forms.TextInput(attrs={'class': 'form-control'})
)
email = forms.EmailField(
widget=forms.EmailInput(attrs={'class': 'form-control'})
)
phone = forms.CharField(
max_length=15,
widget=forms.TextInput(attrs={
'class': 'form-control',
'placeholder': '+1234567890'
})
)
# Resume upload - single file
resume = forms.FileField(
validators=[
FileExtensionValidator(
allowed_extensions=['pdf', 'doc', 'docx'],
message='Only PDF, DOC, and DOCX files are allowed'
)
],
widget=forms.FileInput(attrs={
'class': 'form-control',
'accept': '.pdf,.doc,.docx'
}),
help_text='Upload your resume (PDF, DOC, or DOCX - Max 5MB)'
)
# Cover letter - optional
cover_letter = forms.FileField(
required=False,
validators=[
FileExtensionValidator(
allowed_extensions=['pdf'],
message='Only PDF files are allowed'
)
],
widget=forms.FileInput(attrs={
'class': 'form-control',
'accept': '.pdf'
}),
help_text='Optional: Upload cover letter (PDF - Max 5MB)'
)
# Portfolio images - multiple files
portfolio_images = forms.FileField(
required=False,
widget=forms.ClearableFileInput(attrs={
'class': 'form-control',
'accept': 'image/*',
'multiple': True
}),
help_text='Upload portfolio images (PNG, JPG - Max 2MB each)'
)
def clean_resume(self):
"""Validate resume file"""
resume = self.cleaned_data.get('resume')
if resume:
validate_file_size(resume, max_size_mb=5)
# Additional check for file content
if resume.content_type not in [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
]:
raise ValidationError('Invalid file type')
return resume
def clean_cover_letter(self):
"""Validate cover letter file"""
cover_letter = self.cleaned_data.get('cover_letter')
if cover_letter:
validate_file_size(cover_letter, max_size_mb=5)
return cover_letter
Handling Multiple File Uploads in Views
# views.py
from django.shortcuts import render, redirect
from django.contrib import messages
from django.core.files.storage import FileSystemStorage
import os
def job_application_view(request):
if request.method == 'POST':
form = JobApplicationForm(request.POST, request.FILES)
if form.is_valid():
# Save single files
resume = form.cleaned_data['resume']
cover_letter = form.cleaned_data.get('cover_letter')
# Handle multiple files
portfolio_images = request.FILES.getlist('portfolio_images')
# Validate each portfolio image
valid_extensions = ['png', 'jpg', 'jpeg']
valid_images = []
for image in portfolio_images:
# Check file extension
ext = image.name.split('.')[-1].lower()
if ext not in valid_extensions:
messages.error(
request,
f'Invalid file type for {image.name}. Only PNG and JPG allowed.'
)
continue
# Check file size (2MB max)
if image.size > 2 * 1024 * 1024:
messages.error(
request,
f'{image.name} exceeds 2MB size limit.'
)
continue
# Check if it's actually an image
if not image.content_type.startswith('image/'):
messages.error(
request,
f'{image.name} is not a valid image file.'
)
continue
valid_images.append(image)
# Save files
fs = FileSystemStorage(location='media/applications')
# Create unique folder for this application
import uuid
application_id = uuid.uuid4().hex[:8]
folder_path = f'applications/{application_id}'
# Save resume
resume_path = fs.save(
f'{folder_path}/resume_{resume.name}',
resume
)
# Save cover letter if provided
cover_letter_path = None
if cover_letter:
cover_letter_path = fs.save(
f'{folder_path}/cover_letter_{cover_letter.name}',
cover_letter
)
# Save portfolio images
image_paths = []
for idx, image in enumerate(valid_images, 1):
image_path = fs.save(
f'{folder_path}/portfolio_{idx}_{image.name}',
image
)
image_paths.append(image_path)
# Save to database (example with a model)
# application = JobApplication.objects.create(
# full_name=form.cleaned_data['full_name'],
# email=form.cleaned_data['email'],
# phone=form.cleaned_data['phone'],
# resume=resume_path,
# cover_letter=cover_letter_path,
# )
#
# for image_path in image_paths:
# PortfolioImage.objects.create(
# application=application,
# image=image_path
# )
messages.success(
request,
f'Application submitted successfully! Reference: {application_id}'
)
return redirect('application_success')
else:
messages.error(request, 'Please correct the errors below.')
else:
form = JobApplicationForm()
return render(request, 'job_application.html', {'form': form})
File Upload with Preview (Frontend)
<!-- job_application.html -->
<form method="post" enctype="multipart/form-data" id="applicationForm">
{% csrf_token %}
<!-- Resume upload with preview -->
<div class="form-group">
<label>Resume *</label>
{{ form.resume }}
<small class="form-text text-muted">{{ form.resume.help_text }}</small>
{% if form.resume.errors %}
<div class="text-danger">{{ form.resume.errors }}</div>
{% endif %}
<div id="resume-preview" class="mt-2"></div>
</div>
<!-- Cover letter upload -->
<div class="form-group">
<label>Cover Letter (Optional)</label>
{{ form.cover_letter }}
<small class="form-text text-muted">{{ form.cover_letter.help_text }}</small>
<div id="cover-letter-preview" class="mt-2"></div>
</div>
<!-- Portfolio images with preview -->
<div class="form-group">
<label>Portfolio Images (Optional)</label>
{{ form.portfolio_images }}
<small class="form-text text-muted">{{ form.portfolio_images.help_text }}</small>
<div id="portfolio-preview" class="mt-3 row"></div>
</div>
<button type="submit" class="btn btn-primary">Submit Application</button>
</form>
<script>
// Resume preview
document.querySelector('input[name="resume"]').addEventListener('change', function(e) {
const file = e.target.files[0];
const preview = document.getElementById('resume-preview');
if (file) {
const fileSize = (file.size / (1024 * 1024)).toFixed(2);
const fileType = file.name.split('.').pop().toUpperCase();
preview.innerHTML = `
<div class="alert alert-info">
<i class="fas fa-file-${getFileIcon(fileType)}"></i>
<strong>${file.name}</strong> (${fileSize} MB)
</div>
`;
}
});
// Cover letter preview
document.querySelector('input[name="cover_letter"]').addEventListener('change', function(e) {
const file = e.target.files[0];
const preview = document.getElementById('cover-letter-preview');
if (file) {
const fileSize = (file.size / (1024 * 1024)).toFixed(2);
preview.innerHTML = `
<div class="alert alert-info">
<i class="fas fa-file-pdf"></i>
<strong>${file.name}</strong> (${fileSize} MB)
</div>
`;
}
});
// Portfolio images preview
document.querySelector('input[name="portfolio_images"]').addEventListener('change', function(e) {
const files = Array.from(e.target.files);
const preview = document.getElementById('portfolio-preview');
preview.innerHTML = '';
files.forEach((file, index) => {
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = function(event) {
const fileSize = (file.size / (1024 * 1024)).toFixed(2);
const col = document.createElement('div');
col.className = 'col-md-3 mb-3';
col.innerHTML = `
<div class="card">
<img src="${event.target.result}" class="card-img-top" alt="Preview">
<div class="card-body p-2">
<small class="text-muted">${file.name}</small><br>
<small class="badge badge-info">${fileSize} MB</small>
</div>
</div>
`;
preview.appendChild(col);
};
reader.readAsDataURL(file);
}
});
});
function getFileIcon(type) {
const icons = {
'PDF': 'pdf',
'DOC': 'word',
'DOCX': 'word'
};
return icons[type] || 'alt';
}
</script>
Part 4: Debugging Django Forms - Common Errors and Solutions
Problem 1: Form Doesn't Validate (Always Returns False)
Symptom:
form = MyForm(request.POST)
if form.is_valid(): # Always False!
print("This never executes")
Solution: Check form.errors
form = MyForm(request.POST)
if form.is_valid():
# Process data
pass
else:
# Debug: Print all errors
print("Form errors:")
print(form.errors) # Shows all field errors
print(form.errors.as_json()) # JSON format
# Check specific field
if form.has_error('email'):
print(f"Email errors: {form.errors['email']}")
# Non-field errors (form-wide)
print(f"Non-field errors: {form.non_field_errors()}")
Common causes:
# 1. Missing CSRF token in template
<form method="post">
{% csrf_token %} # DON'T FORGET THIS!
...
</form>
# 2. Wrong enctype for file uploads
<form method="post" enctype="multipart/form-data"> # Required for files!
...
</form>
# 3. Forgetting request.FILES
form = MyForm(request.POST, request.FILES) # Not just request.POST!
# 4. Field name mismatch
# Form field: username
# HTML input: <input name="user_name"> # Wrong!
Problem 2: File Upload Returns None
Symptom:
form = FileUploadForm(request.POST, request.FILES)
if form.is_valid():
file = form.cleaned_data['document']
print(file) # Prints: None
Debug checklist:
# views.py
def upload_view(request):
if request.method == 'POST':
# DEBUG: Check what was sent
print("POST data:", request.POST)
print("FILES data:", request.FILES)
print("Content type:", request.content_type)
form = FileUploadForm(request.POST, request.FILES)
if form.is_valid():
print("Cleaned data:", form.cleaned_data)
else:
print("Form errors:", form.errors)
Common fixes:
<!-- 1. Check form enctype -->
<form method="post" enctype="multipart/form-data">
<!-- 2. Check input name matches form field -->
<!-- Form field: document -->
<input type="file" name="document"> <!-- Must match! -->
<!-- 3. Check Django settings -->
# settings.py
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
# urls.py (development only)
from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [
# ... your patterns
]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
Problem 3: ValidationError Not Showing
Symptom:
def clean_email(self):
email = self.cleaned_data.get('email')
if User.objects.filter(email=email).exists():
raise forms.ValidationError("Email already exists")
return email
# Error not showing in template!
Solution: Check template rendering
<!-- Option 1: Render all errors -->
{{ form.email.errors }}
<!-- Option 2: Check if errors exist -->
{% if form.email.errors %}
<div class="alert alert-danger">
{% for error in form.email.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
<!-- Option 3: Use crispy forms (recommended) -->
{% load crispy_forms_tags %}
{% crispy form %}
Problem 4: Form Submitted Multiple Times
Symptom: User clicks submit, form processes 2-3 times
Solution: Post/Redirect/Get Pattern
# ❌ WRONG: Can cause duplicate submissions
def my_view(request):
if request.method == 'POST':
form = MyForm(request.POST)
if form.is_valid():
form.save()
return render(request, 'success.html') # BAD!
else:
form = MyForm()
return render(request, 'form.html', {'form': form})
# ✅ CORRECT: Redirect after POST
from django.shortcuts import redirect
def my_view(request):
if request.method == 'POST':
form = MyForm(request.POST)
if form.is_valid():
form.save()
messages.success(request, 'Form submitted successfully!')
return redirect('success_page') # GOOD!
else:
form = MyForm()
return render(request, 'form.html', {'form': form})
Plus JavaScript protection:
<script>
document.getElementById('myForm').addEventListener('submit', function(e) {
const submitBtn = this.querySelector('button[type="submit"]');
submitBtn.disabled = true;
submitBtn.textContent = 'Submitting...';
});
</script>
Problem 5: Custom Validation Not Working
Debug with print statements:
class MyForm(forms.Form):
email = forms.EmailField()
def clean_email(self):
email = self.cleaned_data.get('email')
print(f"DEBUG: Cleaning email: {email}") # Add debug prints
if not email:
print("DEBUG: Email is empty")
raise forms.ValidationError("Email is required")
if User.objects.filter(email=email).exists():
print("DEBUG: Email already exists in database")
raise forms.ValidationError("Email already registered")
print("DEBUG: Email validation passed")
return email
def clean(self):
print("DEBUG: Full form clean() called")
print(f"DEBUG: Cleaned data: {self.cleaned_data}")
cleaned_data = super().clean()
# Your validation logic
return cleaned_data
Part 5: ModelForms - Database Integration
Problem: User Profile with Image Upload
# models.py
from django.db import models
from django.contrib.auth.models import User
class UserProfile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE)
bio = models.TextField(max_length=500, blank=True)
birth_date = models.DateField(null=True, blank=True)
avatar = models.ImageField(upload_to='avatars/', null=True, blank=True)
phone = models.CharField(max_length=15, blank=True)
website = models.URLField(blank=True)
def __str__(self):
return f"{self.user.username}'s profile"
# forms.py
from django import forms
from .models import UserProfile
class UserProfileForm(forms.ModelForm):
class Meta:
model = UserProfile
fields = ['bio', 'birth_date', 'avatar', 'phone', 'website']
widgets = {
'bio': forms.Textarea(attrs={
'class': 'form-control',
'rows': 4,
'placeholder': 'Tell us about yourself...'
}),
'birth_date': forms.DateInput(attrs={
'class': 'form-control',
'type': 'date'
}),
'avatar': forms.FileInput(attrs={
'class': 'form-control',
'accept': 'image/*'
}),
'phone': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': '+1234567890'
}),
'website': forms.URLInput(attrs={
'class': 'form-control',
'placeholder': 'https://yourwebsite.com'
}),
}
labels = {
'bio': 'Biography',
'birth_date': 'Date of Birth',
'avatar': 'Profile Picture',
'phone': 'Phone Number',
'website': 'Website URL'
}
help_texts = {
'avatar': 'Upload a profile picture (JPG, PNG - Max 2MB)',
'phone': 'Format: +1234567890'
}
def clean_avatar(self):
"""Validate avatar image"""
avatar = self.cleaned_data.get('avatar')
if avatar:
# Check file size (2MB max)
if avatar.size > 2 * 1024 * 1024:
raise forms.ValidationError(
f'Image size too large. Max 2MB allowed. Current: {avatar.size / (1024*1024):.2f}MB'
)
# Check file type
if not avatar.content_type.startswith('image/'):
raise forms.ValidationError('Only image files are allowed')
# Check image dimensions (optional)
from PIL import Image
try:
img = Image.open(avatar)
width, height = img.size
if width < 100 or height < 100:
raise forms.ValidationError(
'Image dimensions too small. Minimum 100x100 pixels'
)
if width > 4000 or height > 4000:
raise forms.ValidationError(
'Image dimensions too large. Maximum 4000x4000 pixels'
)
except Exception as e:
raise forms.ValidationError(f'Invalid image file: {str(e)}')
return avatar
def clean_phone(self):
"""Validate phone number format"""
phone = self.cleaned_data.get('phone')
if phone:
# Remove spaces and dashes
phone_cleaned = phone.replace(' ', '').replace('-', '')
# Check if starts with + and contains only digits
if not (phone_cleaned.startswith('+') and phone_cleaned[1:].isdigit()):
raise forms.ValidationError(
'Phone must be in format: +1234567890'
)
if len(phone_cleaned) < 10 or len(phone_cleaned) > 15:
raise forms.ValidationError(
'Phone number must be 10-15 digits'
)
return phone_cleaned
return phone
View with Image Preview on Edit
# views.py
from django.shortcuts import render, redirect
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from .forms import UserProfileForm
from .models import UserProfile
@login_required
def edit_profile(request):
# Get or create profile
profile, created = UserProfile.objects.get_or_create(user=request.user)
if request.method == 'POST':
form = UserProfileForm(request.POST, request.FILES, instance=profile)
if form.is_valid():
# Save the form
updated_profile = form.save(commit=False)
updated_profile.user = request.user
# Handle avatar deletion if checkbox checked
if request.POST.get('delete_avatar'):
updated_profile.avatar.delete()
updated_profile.avatar = None
updated_profile.save()
messages.success(request, 'Profile updated successfully!')
return redirect('profile')
else:
messages.error(request, 'Please correct the errors below.')
else:
form = UserProfileForm(instance=profile)
context = {
'form': form,
'profile': profile,
'has_avatar': bool(profile.avatar)
}
return render(request, 'edit_profile.html', context)
Template with Image Preview
<!-- edit_profile.html -->
{% load static %}
<div class="container mt-5">
<h2>Edit Profile</h2>
<form method="post" enctype="multipart/form-data" id="profileForm">
{% csrf_token %}
<!-- Bio -->
<div class="form-group">
<label>{{ form.bio.label }}</label>
{{ form.bio }}
{% if form.bio.errors %}
<div class="text-danger">{{ form.bio.errors }}</div>
{% endif %}
</div>
<!-- Birth Date -->
<div class="form-group">
<label>{{ form.birth_date.label }}</label>
{{ form.birth_date }}
{% if form.birth_date.errors %}
<div class="text-danger">{{ form.birth_date.errors }}</div>
{% endif %}
</div>
<!-- Avatar with Preview -->
<div class="form-group">
<label>{{ form.avatar.label }}</label>
{% if profile.avatar %}
<div class="mb-3">
<img src="{{ profile.avatar.url }}"
alt="Current avatar"
id="current-avatar"
class="img-thumbnail"
style="max-width: 200px;">
<div class="form-check mt-2">
<input type="checkbox"
name="delete_avatar"
id="delete_avatar"
class="form-check-input">
<label for="delete_avatar" class="form-check-label">
Delete current picture
</label>
</div>
</div>
{% endif %}
{{ form.avatar }}
<small class="form-text text-muted">{{ form.avatar.help_text }}</small>
<!-- New image preview -->
<div id="new-avatar-preview" class="mt-3" style="display: none;">
<p class="text-muted">New image preview:</p>
<img id="preview-img" class="img-thumbnail" style="max-width: 200px;">
</div>
{% if form.avatar.errors %}
<div class="text-danger">{{ form.avatar.errors }}</div>
{% endif %}
</div>
<!-- Phone -->
<div class="form-group">
<label>{{ form.phone.label }}</label>
{{ form.phone }}
<small class="form-text text-muted">{{ form.phone.help_text }}</small>
{% if form.phone.errors %}
<div class="text-danger">{{ form.phone.errors }}</div>
{% endif %}
</div>
<!-- Website -->
<div class="form-group">
<label>{{ form.website.label }}</label>
{{ form.website }}
{% if form.website.errors %}
<div class="text-danger">{{ form.website.errors }}</div>
{% endif %}
</div>
<button type="submit" class="btn btn-primary">Save Changes</button>
<a href="{% url 'profile' %}" class="btn btn-secondary">Cancel</a>
</form>
</div>
<script>
// Image preview on file select
document.querySelector('input[name="avatar"]').addEventListener('change', function(e) {
const file = e.target.files[0];
const previewContainer = document.getElementById('new-avatar-preview');
const previewImg = document.getElementById('preview-img');
if (file) {
// Check file size before preview
const sizeMB = file.size / (1024 * 1024);
if (sizeMB > 2) {
alert(`File too large: ${sizeMB.toFixed(2)}MB. Maximum 2MB allowed.`);
this.value = '';
previewContainer.style.display = 'none';
return;
}
// Show preview
const reader = new FileReader();
reader.onload = function(event) {
previewImg.src = event.target.result;
previewContainer.style.display = 'block';
};
reader.readAsDataURL(file);
} else {
previewContainer.style.display = 'none';
}
});
// Hide current avatar if delete is checked
document.getElementById('delete_avatar')?.addEventListener('change', function() {
const currentAvatar = document.getElementById('current-avatar');
if (this.checked) {
currentAvatar.style.opacity = '0.3';
currentAvatar.style.filter = 'grayscale(100%)';
} else {
currentAvatar.style.opacity = '1';
currentAvatar.style.filter = 'none';
}
});
</script>
Part 6: Advanced Techniques - Dynamic Forms
Problem: Add Multiple Items Dynamically (JavaScript + Django)
Use Case: Invoice form where users can add multiple line items
# forms.py
from django import forms
from django.forms import formset_factory
class InvoiceItemForm(forms.Form):
description = forms.CharField(
max_length=200,
widget=forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Item description'
})
)
quantity = forms.IntegerField(
min_value=1,
widget=forms.NumberInput(attrs={
'class': 'form-control',
'placeholder': 'Qty'
})
)
unit_price = forms.DecimalField(
max_digits=10,
decimal_places=2,
widget=forms.NumberInput(attrs={
'class': 'form-control',
'placeholder': '0.00',
'step': '0.01'
})
)
def clean_quantity(self):
quantity = self.cleaned_data.get('quantity')
if quantity and quantity > 1000:
raise forms.ValidationError('Quantity cannot exceed 1000')
return quantity
# Create formset (allows multiple forms)
InvoiceItemFormSet = formset_factory(
InvoiceItemForm,
extra=1, # Number of empty forms to display
max_num=20, # Maximum forms allowed
validate_max=True
)
class InvoiceForm(forms.Form):
invoice_number = forms.CharField(max_length=50)
client_name = forms.CharField(max_length=100)
invoice_date = forms.DateField(widget=forms.DateInput(attrs={'type': 'date'}))
due_date = forms.DateField(widget=forms.DateInput(attrs={'type': 'date'}))
notes = forms.CharField(
required=False,
widget=forms.Textarea(attrs={'rows': 3})
)
View Handling Dynamic Forms
# views.py
from django.shortcuts import render, redirect
from django.contrib import messages
from .forms import InvoiceForm, InvoiceItemFormSet
def create_invoice(request):
if request.method == 'POST':
invoice_form = InvoiceForm(request.POST)
formset = InvoiceItemFormSet(request.POST)
if invoice_form.is_valid() and formset.is_valid():
# Process invoice
invoice_data = invoice_form.cleaned_data
# Process line items
total_amount = 0
items = []
for form in formset:
if form.cleaned_data:
item_data = form.cleaned_data
line_total = item_data['quantity'] * item_data['unit_price']
total_amount += line_total
items.append({
'description': item_data['description'],
'quantity': item_data['quantity'],
'unit_price': item_data['unit_price'],
'line_total': line_total
})
# Save to database or generate PDF
# invoice = Invoice.objects.create(**invoice_data, total=total_amount)
# for item in items:
# InvoiceItem.objects.create(invoice=invoice, **item)
messages.success(
request,
f'Invoice created! Total: ${total_amount:.2f}'
)
return redirect('invoice_list')
else:
# Debug errors
if not invoice_form.is_valid():
print("Invoice form errors:", invoice_form.errors)
if not formset.is_valid():
print("Formset errors:", formset.errors)
messages.error(request, 'Please correct the errors below.')
else:
invoice_form = InvoiceForm()
formset = InvoiceItemFormSet()
context = {
'invoice_form': invoice_form,
'formset': formset
}
return render(request, 'create_invoice.html', context)
Template with Dynamic Add/Remove
<!-- create_invoice.html -->
<form method="post" id="invoiceForm">
{% csrf_token %}
<h3>Invoice Details</h3>
{{ invoice_form.as_p }}
<h3>Line Items</h3>
<div id="formset-container">
{{ formset.management_form }}
<div id="form-list">
{% for form in formset %}
<div class="item-form card mb-3 p-3">
<div class="row">
<div class="col-md-5">
{{ form.description }}
</div>
<div class="col-md-2">
{{ form.quantity }}
</div>
<div class="col-md-3">
{{ form.unit_price }}
</div>
<div class="col-md-2">
<button type="button" class="btn btn-danger btn-sm remove-form">
Remove
</button>
</div>
</div>
{% if form.errors %}
<div class="text-danger mt-2">{{ form.errors }}</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
<button type="button" id="add-form" class="btn btn-secondary mb-3">
Add Line Item
</button>
<div>
<button type="submit" class="btn btn-primary">Create Invoice</button>
</div>
</form>
<script>
document.addEventListener('DOMContentLoaded', function() {
const formList = document.getElementById('form-list');
const addButton = document.getElementById('add-form');
const totalForms = document.querySelector('input[name="form-TOTAL_FORMS"]');
// Add new form
addButton.addEventListener('click', function() {
const formCount = parseInt(totalForms.value);
// Clone the last form
const lastForm = document.querySelector('.item-form:last-child');
const newForm = lastForm.cloneNode(true);
// Update form index
const formRegex = new RegExp('form-(\\d+)-', 'g');
newForm.innerHTML = newForm.innerHTML.replace(formRegex, `form-${formCount}-`);
// Clear input values
newForm.querySelectorAll('input').forEach(input => {
input.value = '';
});
// Add to DOM
formList.appendChild(newForm);
// Increment form count
totalForms.value = formCount + 1;
});
// Remove form (use event delegation)
formList.addEventListener('click', function(e) {
if (e.target.classList.contains('remove-form')) {
const forms = document.querySelectorAll('.item-form');
// Don't remove if only one form left
if (forms.length > 1) {
e.target.closest('.item-form').remove();
// Update form count
totalForms.value = parseInt(totalForms.value) - 1;
// Reindex remaining forms
document.querySelectorAll('.item-form').forEach((form, index) => {
const formRegex = new RegExp('form-(\\d+)-', 'g');
form.innerHTML = form.innerHTML.replace(formRegex, `form-${index}-`);
});
} else {
alert('At least one line item is required');
}
}
});
});
</script>
Part 7: Production Tips and Best Practices
1. Use Django Crispy Forms for Better Rendering
pip install django-crispy-forms crispy-bootstrap4
# settings.py
INSTALLED_APPS = [
# ...
'crispy_forms',
'crispy_bootstrap4',
]
CRISPY_TEMPLATE_PACK = 'bootstrap4'
<!-- Template usage -->
{% load crispy_forms_tags %}
<!-- Render entire form with Bootstrap styling -->
{% crispy form %}
<!-- Or individual fields -->
{{ form.username|as_crispy_field }}
2. Add Client-Side Validation
# forms.py - Add HTML5 validation attributes
class ContactForm(forms.Form):
email = forms.EmailField(
widget=forms.EmailInput(attrs={
'class': 'form-control',
'required': 'required', # HTML5 required
'pattern': '[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}, # HTML5 pattern
})
)
phone = forms.CharField(
widget=forms.TextInput(attrs={
'class': 'form-control',
'pattern': '\\+?[0-9]{10,15}', # Phone pattern
'title': 'Enter 10-15 digit phone number'
})
)
3. Implement AJAX Form Submission
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script>
$('#myForm').on('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
$.ajax({
url: $(this).attr('action'),
type: 'POST',
data: formData,
processData: false,
contentType: false,
success: function(response) {
if (response.success) {
alert('Form submitted successfully!');
$('#myForm')[0].reset();
} else {
// Display errors
displayErrors(response.errors);
}
},
error: function() {
alert('An error occurred. Please try again.');
}
});
});
function displayErrors(errors) {
$('.error-message').remove();
for (let field in errors) {
const errorHtml = `<div class="error-message text-danger">${errors[field]}</div>`;
$(`[name="${field}"]`).after(errorHtml);
}
}
</script>
# views.py - AJAX view
from django.http import JsonResponse
def ajax_form_view(request):
if request.method == 'POST':
form = MyForm(request.POST, request.FILES)
if form.is_valid():
# Process form
form.save()
return JsonResponse({'success': True})
else:
# Return errors as JSON
return JsonResponse({
'success': False,
'errors': form.errors
})
return JsonResponse({'error': 'Invalid request'}, status=400)
4. Security Best Practices
# forms.py
class SecureForm(forms.Form):
# 1. Use PasswordInput for passwords
password = forms.CharField(widget=forms.PasswordInput())
# 2. Add rate limiting (use django-ratelimit)
from django_ratelimit.decorators import ratelimit
# 3. Sanitize input (Django does this automatically with forms)
comment = forms.CharField(
widget=forms.Textarea(),
# Django escapes HTML by default
)
# 4. Validate file uploads strictly
def clean_upload(self):
upload = self.cleaned_data.get('upload')
if upload:
# Check magic bytes, not just extension
import magic
file_type = magic.from_buffer(upload.read(1024), mime=True)
upload.seek(0)
allowed_types = ['image/jpeg', 'image/png', 'application/pdf']
if file_type not in allowed_types:
raise forms.ValidationError('Invalid file type')
return upload
Conclusion: Forms That Just Work
Django Forms transform messy validation logic into clean, maintainable code. Whether you're building a simple contact form or a complex multi-file upload system, Django Forms provide:
✅ Automatic validation (save hours of manual checking)
✅ Security by default (CSRF, XSS protection built-in)
✅ Flexible customization (from basic to advanced)
✅ Easy debugging (form.errors shows exactly what's wrong)
✅ Database integration (ModelForms sync with models)
Key Takeaways:
- Always use
form.cleaned_data(neverrequest.POSTdirectly) - Debug with
form.errorswhen validation fails - Use
enctype="multipart/form-data"for file uploads - Implement Post/Redirect/Get pattern to prevent duplicate submissions
- Add client-side validation for better UX (but always validate server-side!)
Your forms are now bulletproof, user-friendly, and production-ready. Go build something amazing!
Did Django Forms save you debugging time? 👏 Clap if you're never writing manual validation again!
Want more Django deep dives? 🔔 Follow me for tutorials on QuerySet optimization, middleware, and deployment strategies.
Help other Django developers! 📤 Share this guide - everyone struggles with forms at first.
Got a tricky form validation problem? 💬 Drop it in the comments - let's solve it together!
Tags: #Django #DjangoForms #Python #WebDevelopment #FormValidation #FileUpload #Backend #Tutorial #Programming #WebDevelopment
Top comments (0)