Django ModelForm
last modified November 13, 2026 Python 3.15
In this article we create forms from models with ModelForm. We customize the
fields with the Meta options, see how the validation of the model joins the
validation of the form, save new and existing objects, use save(commit=False) to complete an object
before it is written, and edit many objects at once with formsets and inline formsets. The examples
use Django 6.1, Python 3.15, and the default SQLite database.
ModelForm
A form that creates or edits a database object repeats the model: the same fields, the same
types, the same limits. The class ModelForm avoids the repetition. We name a model and the
fields in an inner class Meta, and Django builds the form fields from the model fields: a
CharField becomes a form CharField with the same max_length, a
DecimalField a form DecimalField, a ForeignKey a
ModelChoiceField, which is a select box filled from the related table, and a
ManyToManyField a ModelMultipleChoiceField. The model validators are used by the form, and the
save() method of the form writes the object to the database. Forms in general are described in
Django forms; here we concentrate on what is specific to model forms.
The example project
The project is created with uv; the steps are explained in
Django introduction. The commands below create a project
directory, install Django, generate the project with the configuration package
config, start the app library, and create the folder demos for demo scripts.
$ mkdir modelform $ cd modelform $ uv init --bare $ uv add django $ uv run django-admin startproject config . $ uv run python manage.py startapp library $ mkdir demos
The trailing dot in startproject config . creates manage.py and the
config package in the current directory instead of one level deeper. A new app is
ignored by Django until it is listed in INSTALLED_APPS, so we add
the app to the list in config/settings.py; the other
entries are generated by startproject.
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'library',
]
The listings in this article are of two kinds. A listing with a solid blue border and the label
Project file is a file of the application: models, views, templates, tests, and
settings. A listing with a dashed amber border and the label Demo script is a small
helper in the demos folder; it sets up Django, runs a few lines of code, and prints
the results, so that we can watch the behaviour. Demo scripts are not part of the application;
we run one from the project directory with uv run python -m demos.name.
The model Book has a foreign key to Author, a many-to-many
relation to Genre, a price with a validator, a publication date, and a slug that is
not editable: the field slug has editable=False, so Django never puts it into a model
form, and we set it in the code.
from decimal import Decimal
from django.core.validators import MinValueValidator
from django.db import models
class Author(models.Model):
name = models.CharField(max_length=100, unique=True)
def __str__(self):
return self.name
class Genre(models.Model):
name = models.CharField(max_length=50, unique=True)
def __str__(self):
return self.name
class Book(models.Model):
title = models.CharField(max_length=200)
slug = models.SlugField(max_length=200, blank=True, editable=False)
author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name='books')
genres = models.ManyToManyField(Genre, blank=True)
isbn = models.CharField(max_length=17, unique=True)
price = models.DecimalField(
max_digits=8, decimal_places=2, validators=[MinValueValidator(Decimal('0.01'))])
published = models.DateField()
in_stock = models.BooleanField(default=True)
class Meta:
ordering = ['title']
def __str__(self):
return self.title
The ISBN is stored without the hyphens, but its field allows 17 characters; we will see why when
we validate the form. The tables are created with makemigrations and migrate (see
Django migrations); we apply only the migrations of our app.
$ uv run python manage.py makemigrations library
Migrations for 'library':
library/migrations/0001_initial.py
+ Create model Author
+ Create model Genre
+ Create model Book
$ uv run python manage.py migrate library
Operations to perform:
Apply all migrations: library
Running migrations:
Applying library.0001_initial... OK
Defining a ModelForm
The form class derives from forms.ModelForm. The inner class Meta names the
model and lists the fields that the form contains. We always list them explicitly. The shortcuts
fields = '__all__' and exclude are tempting, but they add every field that we
introduce to the model later, including those that a user must not set, which is a source of security bugs.
The other Meta options customize the generated fields: labels,
help_texts, widgets, and error_messages, each a dictionary keyed by the field name.
from datetime import date
from django import forms
from django.forms import inlineformset_factory, modelformset_factory
from .models import Author, Book
class BookForm(forms.ModelForm):
class Meta:
model = Book
fields = ['title', 'author', 'isbn', 'price', 'published', 'genres', 'in_stock']
labels = {'isbn': 'ISBN'}
help_texts = {'isbn': '13 digits, without hyphens.'}
widgets = {
'published': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
'genres': forms.CheckboxSelectMultiple,
}
error_messages = {
'isbn': {'unique': 'A book with this ISBN already exists.'},
}
def clean_isbn(self):
isbn = self.cleaned_data['isbn'].replace('-', '')
if not (isbn.isdigit() and len(isbn) == 13):
raise forms.ValidationError('An ISBN has exactly 13 digits.')
return isbn
def clean_published(self):
published = self.cleaned_data['published']
if published > date.today():
raise forms.ValidationError('The publication date cannot be in the future.')
return published
The label of the ISBN would be "Isbn", so we set it. The date input gets the HTML type
date, which shows a date picker; the widget must use the ISO format of the value, otherwise the
browser does not display the existing date. The genres are rendered as check boxes instead of a multiple select.
The message for the error unique of the ISBN is replaced by ours. A field can also be declared on the
form like in a plain form, as the class attribute; it then replaces the generated one. The validation methods are explained
in the next section. The next script lists the generated fields.
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from library.forms import BookForm
form = BookForm()
for name, field in form.fields.items():
print(f'{name:10} {type(field).__name__:24} {type(field.widget).__name__:22} required={field.required}')
print()
print('label of isbn:', form.fields['isbn'].label)
print('queryset of author:', form.fields['author'].queryset.model.__name__)
The foreign key became a ModelChoiceField with a queryset over the authors, and the many-to-many field
a ModelMultipleChoiceField; the latter is not required because the model field has
blank=True. The boolean field is not required either, because an unchecked check box sends nothing. The
widgets are the defaults of the field types, except for the two that we replaced.
$ uv run python -m demos.fields title CharField TextInput required=True author ModelChoiceField Select required=True isbn CharField TextInput required=True price DecimalField NumberInput required=True published DateField DateInput required=True genres ModelMultipleChoiceField CheckboxSelectMultiple required=False in_stock BooleanField CheckboxInput required=False label of isbn: ISBN queryset of author: Author
Validation of a ModelForm
A model form validates in the same steps as a plain form: the fields, then the
clean_<field>() methods, then clean(). After that Django builds the model instance from the
cleaned data and runs the model validation on it: full_clean() without the fields that are not in the form, and
the uniqueness checks. The errors of this step are added to the form, so the unique constraint of the ISBN
appears as an error of the field and no extra code is needed (see
Django model validation and signals). Because the fields of the form
are made from the model fields, their limits apply before our code runs: the form field of the ISBN has the
max_length of the model, which is why the model allows 17 characters, enough for the ISBN with hyphens. Our
clean_isbn() removes the hyphens and checks the digits, and the unique check then works with the
normalized value.
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from library.models import Author, Book, Genre
def reset():
Book.objects.all().delete()
Author.objects.all().delete()
Genre.objects.all().delete()
from datetime import date
from library.forms import BookForm
reset()
author = Author.objects.create(name='Ursula K. Le Guin')
Book.objects.create(title='The Dispossessed', author=author, isbn='9780061054884',
price='9.99', published=date(1974, 5, 1))
good = {'title': 'The Left Hand of Darkness', 'author': author.pk, 'isbn': '978-0-441-47812-5',
'price': '8.99', 'published': '1969-03-01'}
cases = {
'valid': good,
'empty': {},
'bad values': {**good, 'isbn': '12345', 'price': '0', 'published': '2999-01-01',
'author': 999},
'duplicate isbn': {**good, 'isbn': '9780061054884'},
}
for title, data in cases.items():
form = BookForm(data)
print(f'{title}: valid={form.is_valid()}')
for field, errors in form.errors.items():
print(f' {field}: {list(errors)}')
reset()
The script validates four data sets against a database that contains one book. The first is valid,
including the hyphenated ISBN. The empty set shows the required fields. The third set breaks several rules at once: the
author does not exist, the ISBN has the wrong length, the date is in the future, and the price is below the minimum of the model
validator. The last one reuses the ISBN of the existing book and gets the message that we configured in
Meta.error_messages.
$ uv run python -m demos.validation valid: valid=True empty: valid=False title: ['This field is required.'] author: ['This field is required.'] isbn: ['This field is required.'] price: ['This field is required.'] published: ['This field is required.'] bad values: valid=False author: ['Select a valid choice. That choice is not one of the available choices.'] isbn: ['An ISBN has exactly 13 digits.'] published: ['The publication date cannot be in the future.'] price: ['Ensure this value is greater than or equal to 0.01.'] duplicate isbn: valid=False isbn: ['A book with this ISBN already exists.']
Saving a ModelForm
The method save() of a valid form creates the object and writes it to the database,
including the many-to-many relations. If the form was created with the argument instance, it updates that
object and does not create a new one; the form is filled with its values. With save(commit=False), the form builds
the object but does not write it: we can set the values that are not in the form, such as our slug,
the current user, or a generated token, and call save() on the object ourselves. A many-to-many relation
needs the primary keys of both sides, so with commit=False it is not saved either, and we must call
form.save_m2m() after the object is saved. Calling save() on an invalid form raises
ValueError.
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from library.models import Author, Book, Genre
def reset():
Book.objects.all().delete()
Author.objects.all().delete()
Genre.objects.all().delete()
from datetime import date
from django.utils.text import slugify
from library.forms import BookForm
reset()
author = Author.objects.create(name='Ursula K. Le Guin')
fantasy = Genre.objects.create(name='Fantasy')
scifi = Genre.objects.create(name='Science fiction')
data = {'title': 'The Left Hand of Darkness', 'author': author.pk, 'isbn': '978-0-441-47812-5',
'price': '8.99', 'published': '1969-03-01', 'genres': [scifi.pk], 'in_stock': 'on'}
form = BookForm(data)
book = form.save(commit=False)
print('after commit=False:', book.pk, repr(book.slug), book.isbn)
book.slug = slugify(book.title)
book.save()
print('after save():', book.pk, book.slug, list(book.genres.all()))
form.save_m2m()
print('after save_m2m():', list(book.genres.all()))
edit = BookForm({**data, 'isbn': '9780441478125', 'title': 'Left Hand of Darkness', 'price': '10.50',
'genres': [scifi.pk, fantasy.pk]}, instance=book)
print('same object:', edit.instance is book, 'changed:', edit.is_valid() and edit.changed_data)
edit.save()
book.refresh_from_db()
print(book.title, book.price, sorted(g.name for g in book.genres.all()), Book.objects.count())
reset()
After save(commit=False) the object has no primary key, the slug is empty, and the ISBN is
already normalized. After save() the object has a key and the slug that we set, but the genres are
still empty; save_m2m() fills them. The second form edits the same object: instance
is the object that we passed, changed_data lists the fields that differ from the stored values, and the save updates the
row and the genres instead of adding a book. The slug is not touched, because it is not in the form.
$ uv run python -m demos.save after commit=False: None '' 9780441478125 after save(): 2 the-left-hand-of-darkness [] after save_m2m(): [<Genre: Science fiction>] same object: True changed: ['title', 'price', 'genres'] Left Hand of Darkness 10.50 ['Fantasy', 'Science fiction'] 1
Views and templates
The view of a model form is the same as for a plain form, with two differences: we pass the object to edit
as instance, and the saved object is the result of save(). One view creates and edits: when it
gets a primary key from the URL it loads the object with get_object_or_404(), otherwise
instance is None and the form creates a new book. The expression
request.POST or None gives an unbound form for a GET request, because an empty
QueryDict is false. When the form is valid, we complete the object with
commit=False and redirect, as described in Django forms. The class-based
CreateView and UpdateView of Django class-based views do the same without
the view code.
from django.shortcuts import get_object_or_404, redirect, render
from django.utils.text import slugify
from .forms import AuthorBookFormSet, BookForm
from .models import Author, Book
def book_list(request):
books = Book.objects.select_related('author')
return render(request, 'library/book_list.html', {'books': books})
def book_form(request, pk=None):
book = get_object_or_404(Book, pk=pk) if pk else None
form = BookForm(request.POST or None, instance=book)
if request.method == 'POST' and form.is_valid():
book = form.save(commit=False)
book.slug = slugify(book.title)
book.save()
form.save_m2m()
return redirect('library:list')
return render(request, 'library/book_form.html', {'form': form, 'book': book})
The list page uses select_related() to read the authors in the same query (see
Django query performance). The URLs give the pages the names that the templates use.
from django.urls import path
from . import views
app_name = 'library'
urlpatterns = [
path('', views.book_list, name='list'),
path('new/', views.book_form, name='create'),
path('<int:pk>/edit/', views.book_form, name='edit'),
path('authors/<int:pk>/books/', views.author_books, name='author_books'),
]
The three pages are served below the prefix books/, which the root
config/urls.py configures with include().
from django.urls import include, path
urlpatterns = [
path('books/', include('library.urls')),
]
The templates follow; the base template is the usual one.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{% block title %}Library{% endblock %}</title>
</head>
<body>
<h1>{% block heading %}Library{% endblock %}</h1>
{% block content %}{% endblock %}
</body>
</html>
The list of books links to the edit page of each book and to the form for a new one.
{% extends 'library/base.html' %}
{% block content %}
<ul>
{% for book in books %}
<li><a href="{% url 'library:edit' book.pk %}">{{ book.title }}</a> by {{ book.author }}</li>
{% endfor %}
</ul>
<p><a href="{% url 'library:create' %}">Add a book</a></p>
{% endblock %}
The form template renders the form with {{ form }}, and its heading depends on whether we edit a
book.
{% extends 'library/base.html' %}
{% block heading %}{% if book %}Edit {{ book }}{% else %}New book{% endif %}{% endblock %}
{% block content %}
<form method="post">
{% csrf_token %}
{{ form }}
<button type="submit">Save</button>
</form>
{% endblock %}
The script uses the test client against the project database. It loads the empty form, posts invalid data and a valid one, and then edits the book. The edit page shows how the form is filled from the instance: the text input has the title as the value, the date input has the date in the ISO format, the check box of the genre is checked, and the author is the selected option of the select box. The post to the edit URL updates the same row, and the number of books stays one.
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from library.models import Author, Book, Genre
def reset():
Book.objects.all().delete()
Author.objects.all().delete()
Genre.objects.all().delete()
import re
from datetime import date
from django.test import Client
from django.test.utils import setup_test_environment
setup_test_environment()
reset()
author = Author.objects.create(name='Ursula K. Le Guin')
genre = Genre.objects.create(name='Science fiction')
client = Client()
response = client.get('/books/new/')
print('GET /books/new/:', response.status_code, [f.name for f in response.context['form']])
data = {'title': 'The Dispossessed', 'author': author.pk, 'isbn': '9780061054884',
'price': '9.99', 'published': '1974-05-01', 'genres': [genre.pk], 'in_stock': 'on'}
response = client.post('/books/new/', {**data, 'isbn': '123'})
print('POST invalid:', response.status_code, response.context['form'].errors.as_text())
response = client.post('/books/new/', data)
print('POST valid:', response.status_code, response['Location'])
book = Book.objects.get()
print('saved:', book.slug, book.price, list(book.genres.all()))
response = client.get(f'/books/{book.pk}/edit/')
html = response.content.decode()
for pattern in (r'<input type="text" name="title"[^>]*>', r'<input type="date"[^>]*>',
r'<input type="checkbox" name="genres"[^>]*>'):
print(re.search(pattern, html).group(0))
print(re.search(r'<option value="\d+" selected>[^<]*</option>', html).group(0))
response = client.post(f'/books/{book.pk}/edit/', {**data, 'price': '12.00'})
book.refresh_from_db()
print('POST edit:', response.status_code, book.price, Book.objects.count())
reset()
The invalid post returns the form with the error (status 200); the valid one saves the book, with the slug that the view has set, and redirects to the list.
$ uv run python -m demos.flow GET /books/new/: 200 ['title', 'author', 'isbn', 'price', 'published', 'genres', 'in_stock'] POST invalid: 200 * isbn * An ISBN has exactly 13 digits. POST valid: 302 /books/ saved: the-dispossessed 9.99 [<Genre: Science fiction>] <input type="text" name="title" value="The Dispossessed" maxlength="200" required id="id_title"> <input type="date" name="published" value="1974-05-01" required id="id_published"> <input type="checkbox" name="genres" value="3" id="id_genres_0" checked> <option value="3" selected>Ursula K. Le Guin</option> POST edit: 302 12.00 1
Model formsets
A formset is a collection of forms of the same kind, which lets us edit many objects on one page. A
model formset has a form for each object of a queryset, and it is created with
modelformset_factory(), which takes the model, the fields, and the number of extra empty forms
for new objects. The formset needs a management form, a few hidden fields with the number of forms in the data,
which Django uses to know how many forms to process; in a template we output it with
{{ formset.management_form }}. The names of the fields are prefixed with form-
and the position of the form, such as form-0-price. Existing objects are identified by a hidden
id field. The formset validates every form, and save() saves
only the forms that have changed.
from django.forms import inlineformset_factory, modelformset_factory
...
BookPriceFormSet = modelformset_factory(Book, fields=['price', 'in_stock'], extra=0)
AuthorBookFormSet = inlineformset_factory(
Author, Book, fields=['title', 'isbn', 'price', 'published'], extra=1, can_delete=True)
The first formset edits the price and the stock flag of books. With extra=0 it shows only the existing
objects. The second is an inline formset, made with inlineformset_factory(): it edits the objects that
belong to a parent object, here the books of an author, and sets the foreign key itself. The option can_delete adds a
delete check box to every form. The prefix of an inline formset is the name of the reverse relation, here books.
from django.shortcuts import get_object_or_404, redirect, render
from .forms import AuthorBookFormSet
from .models import Author
...
def author_books(request, pk):
author = get_object_or_404(Author, pk=pk)
formset = AuthorBookFormSet(request.POST or None, instance=author)
if request.method == 'POST' and formset.is_valid():
formset.save()
return redirect('library:author_books', pk=author.pk)
return render(request, 'library/author_books.html', {'author': author, 'formset': formset})
The view creates the inline formset for the author, with the same handling of GET and POST as for a single form. In the template, the management form is output once and each form is rendered in a loop.
{% extends 'library/base.html' %}
{% block heading %}Books of {{ author }}{% endblock %}
{% block content %}
<form method="post">
{% csrf_token %}
{{ formset.management_form }}
{% for form in formset %}
<fieldset>
{{ form.as_p }}
</fieldset>
{% endfor %}
<button type="submit">Save</button>
</form>
{% endblock %}
The next script works with the formsets without the browser. It prints the management form of the model formset
and posts data in the form that a browser would send. The first post contains an invalid price, so the formset is not valid, and its
errors list has one dictionary for each form. The second is valid; the unchecked check box of the second book
sends no value, so its flag becomes false, and save() returns the objects of the forms that have
changed, which are all three books.
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from library.models import Author, Book, Genre
def reset():
Book.objects.all().delete()
Author.objects.all().delete()
Genre.objects.all().delete()
from datetime import date
from library.forms import AuthorBookFormSet, BookPriceFormSet
reset()
author = Author.objects.create(name='Ursula K. Le Guin')
b1 = Book.objects.create(title='A Wizard of Earthsea', author=author, isbn='9780547773742',
price='8.00', published=date(1968, 11, 1))
b2 = Book.objects.create(title='The Dispossessed', author=author, isbn='9780061054884',
price='9.99', published=date(1974, 5, 1))
b3 = Book.objects.create(title='The Lathe of Heaven', author=author, isbn='9781416556961',
price='7.50', published=date(1971, 1, 1))
print('--- model formset')
formset = BookPriceFormSet(queryset=Book.objects.order_by('pk'))
print(formset.management_form.as_div().replace('<input', '\n<input').strip())
print('forms:', [str(form.instance) for form in formset])
data = {
'form-TOTAL_FORMS': '3', 'form-INITIAL_FORMS': '3',
'form-0-id': b1.pk, 'form-0-price': '8.50', 'form-0-in_stock': 'on',
'form-1-id': b2.pk, 'form-1-price': '9.99', 'form-1-in_stock': 'on',
'form-2-id': b3.pk, 'form-2-price': '-1', 'form-2-in_stock': 'on',
}
formset = BookPriceFormSet(data, queryset=Book.objects.order_by('pk'))
print('valid:', formset.is_valid(), formset.errors)
data['form-2-price'] = '7.00'
data['form-1-in_stock'] = ''
formset = BookPriceFormSet(data, queryset=Book.objects.order_by('pk'))
print('valid:', formset.is_valid())
saved = formset.save()
print('saved:', [(book.title, str(book.price), book.in_stock) for book in saved])
print('--- inline formset')
formset = AuthorBookFormSet(instance=author)
print('prefix:', formset.prefix, 'forms:', len(formset.forms), 'extra form is empty:', not formset.forms[-1].instance.pk)
data = {
'books-TOTAL_FORMS': '4', 'books-INITIAL_FORMS': '3', 'books-MIN_NUM_FORMS': '0',
'books-MAX_NUM_FORMS': '1000',
}
for i, book in enumerate(Book.objects.order_by('pk')):
data.update({f'books-{i}-id': book.pk, f'books-{i}-author': author.pk,
f'books-{i}-title': book.title, f'books-{i}-isbn': book.isbn,
f'books-{i}-price': str(book.price), f'books-{i}-published': book.published})
data['books-0-title'] = 'Earthsea'
data['books-1-DELETE'] = 'on'
data.update({'books-3-author': author.pk, 'books-3-title': 'The Word for World Is Forest',
'books-3-isbn': '9780765325426', 'books-3-price': '6.99',
'books-3-published': '1972-01-01'})
formset = AuthorBookFormSet(data, instance=author)
print('valid:', formset.is_valid())
formset.save()
print('new:', [b.title for b in formset.new_objects])
print('changed:', [(b.title, fields) for b, fields in formset.changed_objects])
print('deleted:', [b.title for b in formset.deleted_objects])
print('books now:', list(author.books.values_list('title', flat=True)))
reset()
The second book got its flag False and the others got the new prices. For the inline formset, the script
posts four forms: the first book is renamed, the second is marked for deletion, the third is unchanged, and the fourth is the
new one. After saving, the formset reports the lists new_objects, changed_objects, with the names of the
changed fields, and deleted_objects; the unchanged book is in none of them, and the author has three books at the
end.
$ uv run python -m demos.formset
--- model formset
<input type="hidden" name="form-TOTAL_FORMS" value="3" id="id_form-TOTAL_FORMS">
<input type="hidden" name="form-INITIAL_FORMS" value="3" id="id_form-INITIAL_FORMS">
<input type="hidden" name="form-MIN_NUM_FORMS" value="0" id="id_form-MIN_NUM_FORMS">
<input type="hidden" name="form-MAX_NUM_FORMS" value="1000" id="id_form-MAX_NUM_FORMS">
forms: ['A Wizard of Earthsea', 'The Dispossessed', 'The Lathe of Heaven']
valid: False [{}, {}, {'price': ['Ensure this value is greater than or equal to 0.01.']}]
valid: True
saved: [('A Wizard of Earthsea', '8.50', True), ('The Dispossessed', '9.99', False), ('The Lathe of Heaven', '7.00', True)]
--- inline formset
prefix: books forms: 4 extra form is empty: True
valid: True
new: ['The Word for World Is Forest']
changed: [('Earthsea', ['title'])]
deleted: ['The Dispossessed']
books now: ['Earthsea', 'The Lathe of Heaven', 'The Word for World Is Forest']
Testing model forms
The tests of the forms cover the normalization of the ISBN, the date rule, the custom unique message, the update of an
existing instance, and the fact that commit=False writes nothing. The view tests check the slug, the redirect, the
404 of a missing object, and the deletion through an inline formset.
from datetime import date
from django.test import TestCase
from django.urls import reverse
from .forms import AuthorBookFormSet, BookForm
from .models import Author, Book, Genre
class BookFormTests(TestCase):
def setUp(self):
self.author = Author.objects.create(name='Ursula K. Le Guin')
self.genre = Genre.objects.create(name='Science fiction')
self.data = {
'title': 'The Dispossessed', 'author': self.author.pk,
'isbn': '978-0-06-105488-4', 'price': '9.99', 'published': '1974-05-01',
'genres': [self.genre.pk], 'in_stock': 'on',
}
def test_isbn_is_normalized(self):
form = BookForm(self.data)
self.assertTrue(form.is_valid(), form.errors)
self.assertEqual(form.cleaned_data['isbn'], '9780061054884')
def test_future_date_is_rejected(self):
form = BookForm({**self.data, 'published': '2999-01-01'})
self.assertIn('published', form.errors)
def test_duplicate_isbn_uses_the_custom_message(self):
BookForm(self.data).save()
form = BookForm({**self.data, 'title': 'Another'})
self.assertFalse(form.is_valid())
self.assertEqual(form.errors['isbn'], ['A book with this ISBN already exists.'])
def test_editing_updates_the_instance(self):
book = BookForm(self.data).save()
form = BookForm({**self.data, 'price': '12.00'}, instance=book)
form.save()
book.refresh_from_db()
self.assertEqual(str(book.price), '12.00')
self.assertEqual(Book.objects.count(), 1)
def test_commit_false_defers_the_database_write(self):
book = BookForm(self.data).save(commit=False)
self.assertIsNone(book.pk)
self.assertEqual(Book.objects.count(), 0)
class ViewTests(TestCase):
def setUp(self):
self.author = Author.objects.create(name='Ursula K. Le Guin')
def test_create_sets_the_slug_and_redirects(self):
response = self.client.post(reverse('library:create'), {
'title': 'The Dispossessed', 'author': self.author.pk,
'isbn': '9780061054884', 'price': '9.99', 'published': '1974-05-01'})
self.assertRedirects(response, reverse('library:list'))
self.assertEqual(Book.objects.get().slug, 'the-dispossessed')
def test_edit_of_a_missing_book_is_404(self):
response = self.client.get(reverse('library:edit', args=[999]))
self.assertEqual(response.status_code, 404)
def test_inline_formset_deletes_a_book(self):
book = Book.objects.create(title='Old', author=self.author, isbn='9780061054884',
price='9.99', published=date(1974, 5, 1))
data = {
'books-TOTAL_FORMS': '1', 'books-INITIAL_FORMS': '1',
'books-0-id': book.pk, 'books-0-author': self.author.pk,
'books-0-title': 'Old', 'books-0-isbn': book.isbn, 'books-0-price': '9.99',
'books-0-published': '1974-05-01', 'books-0-DELETE': 'on',
}
formset = AuthorBookFormSet(data, instance=self.author)
self.assertTrue(formset.is_valid(), formset.errors)
formset.save()
self.assertEqual(Book.objects.count(), 0)
All tests run against a test database that Django creates for the run, as described in Django database configuration.
$ uv run python manage.py test Found 8 test(s). Creating test database for alias 'default'... System check identified no issues (0 silenced). ........ ---------------------------------------------------------------------- Ran 8 tests in 0.027s OK Destroying test database for alias 'default'...
Source
Django documentation: Creating forms from models
Django documentation: Formsets
Django documentation: Model Form Functions
In this article we have created forms from models with ModelForm, customized them with
the Meta options, validated and saved them, used save(commit=False) and
save_m2m(), and edited many objects with model formsets and inline formsets. Related articles are
Django forms, Django models, and
Django model validation and signals.
Author
List all Django tutorials.