ZetCode

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.

config/settings.py
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.

library/models.py
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.

library/forms.py (BookForm)
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.

demos/fields.py
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.

demos/validation.py
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.

demos/save.py
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.

library/views.py (book views)
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.

library/urls.py
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().

config/urls.py
from django.urls import include, path

urlpatterns = [
    path('books/', include('library.urls')),
]

The templates follow; the base template is the usual one.

library/templates/library/base.html
<!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.

library/templates/library/book_list.html
{% 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.

library/templates/library/book_form.html
{% 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.

demos/flow.py
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.

library/forms.py (formsets)
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.

library/views.py (author_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.

library/templates/library/author_books.html
{% 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.

demos/formset.py
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.

library/tests.py
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

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Django tutorials.