基本

Djangoフォーム入門|Form・ModelForm・バリデーションの基本

Django フォーム Python

Djangoフォーム入門
Form・ModelForm・バリデーションの基本

Djangoのフォームシステムを基礎から解説。Form、ModelForm、バリデーション、ウィジェットカスタマイズ、FormSetまで学べます。

こんな人向けの記事です

  • Djangoフォームの作り方を学びたい
  • ModelFormでモデルと連携したフォームを作りたい
  • バリデーションの実装方法を理解したい

Step 1Djangoフォームの基本(forms.Form)

Djangoのフォームクラスを使えば、HTMLフォームの生成・データ検証・エラー表示を一元管理できます。生のHTMLで<input>タグを書く方法と比べて、セキュリティ(CSRF対策)やバリデーションが自動で組み込まれる点が大きなメリットです。

基本的なフォームクラスの定義

forms.py
from django import forms

class ContactForm(forms.Form):
    name = forms.CharField(
        label='お名前',
        max_length=100,
        widget=forms.TextInput(attrs={'placeholder': '山田太郎'})
    )
    email = forms.EmailField(
        label='メールアドレス',
        widget=forms.EmailInput(attrs={'placeholder': 'example@mail.com'})
    )
    message = forms.CharField(
        label='お問い合わせ内容',
        widget=forms.Textarea(attrs={'rows': 5})
    )

ビューでフォームを使う

views.py
from django.shortcuts import render, redirect
from .forms import ContactForm

def contact_view(request):
    if request.method == 'POST':
        form = ContactForm(request.POST)
        if form.is_valid():
            # バリデーション済みデータを取得
            name = form.cleaned_data['name']
            email = form.cleaned_data['email']
            message = form.cleaned_data['message']
            # メール送信などの処理
            return redirect('contact_done')
    else:
        form = ContactForm()

    return render(request, 'contact.html', {'form': form})

テンプレートでフォームを表示

templates/contact.html
<form method="post">
    {% csrf_token %}
    {{ form.as_p }}
    <button type="submit">送信</button>
</form>
フォームの表示メソッド
form.as_p は各フィールドを<p>タグで囲んで出力します。他にも form.as_div(div タグ)、form.as_table(table タグ)、form.as_ul(ul タグ)が使えます。Django 5.0 以降では as_div がデフォルト推奨です。

よく使うフィールドタイプ

フィールド用途HTMLの出力
CharFieldテキスト入力<input type="text">
EmailFieldメールアドレス<input type="email">
IntegerField整数<input type="number">
BooleanFieldチェックボックス<input type="checkbox">
ChoiceFieldセレクトボックス<select>
DateField日付入力<input type="text">
FileFieldファイルアップロード<input type="file">

Step 2ModelForm(モデルと連携したフォーム)

ModelFormを使えば、モデルの定義から自動的にフォームを生成できます。CRUD操作の実装が大幅に簡単になります。

モデルの定義

models.py
from django.db import models

class BlogPost(models.Model):
    title = models.CharField('タイトル', max_length=200)
    content = models.TextField('本文')
    category = models.CharField('カテゴリ', max_length=50, choices=[
        ('tech', '技術'),
        ('life', '生活'),
        ('other', 'その他'),
    ])
    is_published = models.BooleanField('公開', default=False)
    created_at = models.DateTimeField('作成日時', auto_now_add=True)

    def __str__(self):
        return self.title

ModelFormの定義

forms.py
from django import forms
from .models import BlogPost

class BlogPostForm(forms.ModelForm):
    class Meta:
        model = BlogPost
        fields = ['title', 'content', 'category', 'is_published']
        # fields = '__all__'  # 全フィールドを含める場合
        # exclude = ['created_at']  # 特定フィールドを除外する場合
        widgets = {
            'title': forms.TextInput(attrs={
                'class': 'form-control',
                'placeholder': 'タイトルを入力'
            }),
            'content': forms.Textarea(attrs={
                'class': 'form-control',
                'rows': 10
            }),
        }
        labels = {
            'title': '記事タイトル',
            'content': '記事本文',
        }
        help_texts = {
            'title': '200文字以内で入力してください。',
        }

ModelFormを使ったCRUDビュー

views.py
from django.shortcuts import render, redirect, get_object_or_404
from .forms import BlogPostForm
from .models import BlogPost

# 新規作成
def post_create(request):
    if request.method == 'POST':
        form = BlogPostForm(request.POST)
        if form.is_valid():
            post = form.save()  # モデルに保存
            return redirect('post_detail', pk=post.pk)
    else:
        form = BlogPostForm()
    return render(request, 'post_form.html', {'form': form})

# 編集
def post_update(request, pk):
    post = get_object_or_404(BlogPost, pk=pk)
    if request.method == 'POST':
        form = BlogPostForm(request.POST, instance=post)
        if form.is_valid():
            form.save()  # 既存レコードを更新
            return redirect('post_detail', pk=post.pk)
    else:
        form = BlogPostForm(instance=post)  # 既存データを初期値に
    return render(request, 'post_form.html', {'form': form})
fieldsの指定は必須
ModelFormではfieldsまたはexcludeを必ず指定してください。指定しないとエラーになります。セキュリティの観点から、fields = '__all__'よりも必要なフィールドを明示的に列挙する方が安全です。
save(commit=False)の使い方
form.save(commit=False)を使うと、データベースに保存せずにモデルインスタンスを取得できます。保存前にフィールドを追加・変更したい場合に便利です。

post = form.save(commit=False)
post.author = request.user # ログインユーザーを設定
post.save()

Step 3フォームのバリデーション(cleanメソッド)

Djangoフォームには複数段階のバリデーションがあります。フィールド単位のバリデーションから、複数フィールドをまたいだ検証まで柔軟に実装できます。

バリデーションの流れ

順序メソッド対象用途
1to_python()各フィールド値をPythonオブジェクトに変換
2validate()各フィールドフィールド固有のバリデーション
3run_validators()各フィールドバリデータの実行
4clean_<fieldname>()各フィールドフィールド単位のカスタムバリデーション
5clean()フォーム全体複数フィールドにまたがるバリデーション

フィールド単位のバリデーション(clean_<fieldname>)

forms.py
from django import forms
from django.core.exceptions import ValidationError

class RegistrationForm(forms.Form):
    username = forms.CharField(max_length=30)
    email = forms.EmailField()
    password = forms.CharField(widget=forms.PasswordInput)
    password_confirm = forms.CharField(
        label='パスワード(確認)',
        widget=forms.PasswordInput
    )

    def clean_username(self):
        """ユーザー名のカスタムバリデーション"""
        username = self.cleaned_data['username']
        if len(username) < 3:
            raise ValidationError('ユーザー名は3文字以上にしてください。')
        if not username.isalnum():
            raise ValidationError('ユーザー名は英数字のみ使用できます。')
        return username

    def clean_email(self):
        """メールアドレスの重複チェック"""
        email = self.cleaned_data['email']
        from django.contrib.auth.models import User
        if User.objects.filter(email=email).exists():
            raise ValidationError('このメールアドレスは既に登録されています。')
        return email

フォーム全体のバリデーション(clean)

forms.py(続き)
    def clean(self):
        """複数フィールドにまたがるバリデーション"""
        cleaned_data = super().clean()
        password = cleaned_data.get('password')
        password_confirm = cleaned_data.get('password_confirm')

        if password and password_confirm:
            if password != password_confirm:
                raise ValidationError('パスワードが一致しません。')

        return cleaned_data

バリデータを使う方法

validators.py
from django.core.validators import (
    MinLengthValidator,
    MaxLengthValidator,
    RegexValidator,
)
from django import forms

class ProductForm(forms.Form):
    name = forms.CharField(
        validators=[MinLengthValidator(2, 'もっと長い名前にしてください。')]
    )
    code = forms.CharField(
        validators=[
            RegexValidator(
                regex=r'^[A-Z]{2}-\d{4}$',
                message='コードは「XX-0000」の形式で入力してください。'
            )
        ]
    )
    price = forms.IntegerField(
        min_value=0,       # 0以上
        max_value=1000000  # 100万以下
    )

テンプレートでエラーを表示

templates/form.html
<form method="post">
    {% csrf_token %}

    {# フォーム全体のエラー #}
    {% if form.non_field_errors %}
    <div class="alert alert-danger">
        {% for error in form.non_field_errors %}
            <p>{{ error }}</p>
        {% endfor %}
    </div>
    {% endif %}

    {# 各フィールドを個別に表示 #}
    {% for field in form %}
    <div class="form-group {% if field.errors %}has-error{% endif %}">
        <label>{{ field.label }}</label>
        {{ field }}
        {% if field.errors %}
        <ul class="error-list">
            {% for error in field.errors %}
            <li>{{ error }}</li>
            {% endfor %}
        </ul>
        {% endif %}
        {% if field.help_text %}
        <small>{{ field.help_text }}</small>
        {% endif %}
    </div>
    {% endfor %}

    <button type="submit">登録</button>
</form>

Step 4フォームウィジェットのカスタマイズ

ウィジェットは、フォームフィールドのHTML表現を制御するクラスです。見た目やクラス名、属性を自在にカスタマイズできます。

主なウィジェット一覧

ウィジェットHTML出力主な用途
TextInput<input type="text">短いテキスト
Textarea<textarea>長いテキスト
PasswordInput<input type="password">パスワード
NumberInput<input type="number">数値入力
EmailInput<input type="email">メールアドレス
DateInput<input type="date">日付選択
Select<select>単一選択
SelectMultiple<select multiple>複数選択
CheckboxInput<input type="checkbox">チェックボックス
RadioSelect<input type="radio">ラジオボタン
FileInput<input type="file">ファイル選択
HiddenInput<input type="hidden">非表示値

ウィジェットにCSSクラスやHTML属性を設定する

forms.py
class StyledForm(forms.Form):
    # 定義時に指定
    name = forms.CharField(
        widget=forms.TextInput(attrs={
            'class': 'form-control',
            'placeholder': '名前を入力',
            'id': 'input-name',
            'autofocus': True,
        })
    )

    # ChoiceFieldをラジオボタンにする
    gender = forms.ChoiceField(
        choices=[('male', '男性'), ('female', '女性'), ('other', 'その他')],
        widget=forms.RadioSelect(attrs={'class': 'radio-inline'})
    )

    # 日付入力にHTML5のdateピッカーを使う
    birthday = forms.DateField(
        widget=forms.DateInput(attrs={
            'type': 'date',
            'class': 'form-control',
        })
    )

__init__でウィジェットを動的にカスタマイズ

forms.py
class DynamicForm(forms.ModelForm):
    class Meta:
        model = BlogPost
        fields = ['title', 'content', 'category']

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        # 全フィールドにBootstrapのクラスを一括適用
        for field_name, field in self.fields.items():
            field.widget.attrs['class'] = 'form-control'
            field.widget.attrs['autocomplete'] = 'off'

        # 特定フィールドだけ変更
        self.fields['title'].widget.attrs['placeholder'] = 'タイトルを入力'
        self.fields['content'].widget.attrs['rows'] = 8
django-widget-tweaks
テンプレート側でウィジェットの属性を操作したい場合、django-widget-tweaksパッケージが便利です。
{{ form.name|add_class:"form-control" }} のように記述できます。

Step 5フォームセット(FormSet)

FormSetは、同じフォームを複数同時に表示・処理するための仕組みです。一括登録や一括編集の画面を作るときに使います。

基本的なFormSetの使い方

forms.py
from django import forms
from django.forms import formset_factory

class ItemForm(forms.Form):
    name = forms.CharField(label='商品名', max_length=100)
    price = forms.IntegerField(label='価格', min_value=0)
    quantity = forms.IntegerField(label='数量', min_value=1)

# FormSetを作成(最大5件、追加フォーム3件)
ItemFormSet = formset_factory(
    ItemForm,
    extra=3,       # 空のフォームを3つ表示
    max_num=5,     # 最大5件まで
    min_num=1,     # 最低1件は必須
    validate_min=True,
    validate_max=True,
)

ビューでFormSetを処理

views.py
def item_create(request):
    if request.method == 'POST':
        formset = ItemFormSet(request.POST)
        if formset.is_valid():
            for form in formset:
                if form.cleaned_data:  # 空のフォームをスキップ
                    name = form.cleaned_data['name']
                    price = form.cleaned_data['price']
                    quantity = form.cleaned_data['quantity']
                    # データベースに保存する処理
            return redirect('item_list')
    else:
        formset = ItemFormSet()

    return render(request, 'item_form.html', {'formset': formset})

テンプレートでFormSetを表示

templates/item_form.html
<form method="post">
    {% csrf_token %}
    {{ formset.management_form }}

    {% for form in formset %}
    <div class="formset-row">
        <h4>商品 {{ forloop.counter }}</h4>
        {{ form.as_p }}
    </div>
    {% endfor %}

    <button type="submit">一括登録</button>
</form>
management_formを忘れない
FormSetを使うテンプレートでは、必ず{{ formset.management_form }}を記述してください。これがないとDjangoがフォームの数を正しく認識できず、ManagementForm data is missing or has been tampered withエラーが発生します。

ModelFormSetの使い方

forms.py
from django.forms import modelformset_factory
from .models import BlogPost

BlogPostFormSet = modelformset_factory(
    BlogPost,
    fields=['title', 'content', 'is_published'],
    extra=2,       # 新規追加用のフォーム数
    can_delete=True # 削除チェックボックスを表示
)
views.py
def post_bulk_edit(request):
    if request.method == 'POST':
        formset = BlogPostFormSet(request.POST)
        if formset.is_valid():
            formset.save()  # まとめて保存&削除
            return redirect('post_list')
    else:
        formset = BlogPostFormSet(
            queryset=BlogPost.objects.filter(is_published=False)
        )
    return render(request, 'post_bulk_edit.html', {'formset': formset})

InlineFormSet(親子関係のフォーム)

forms.py
from django.forms import inlineformset_factory
from .models import Order, OrderItem

OrderItemFormSet = inlineformset_factory(
    Order,           # 親モデル
    OrderItem,       # 子モデル
    fields=['product', 'quantity', 'price'],
    extra=3,
    can_delete=True,
)
views.py
def order_edit(request, pk):
    order = get_object_or_404(Order, pk=pk)
    if request.method == 'POST':
        formset = OrderItemFormSet(request.POST, instance=order)
        if formset.is_valid():
            formset.save()
            return redirect('order_detail', pk=pk)
    else:
        formset = OrderItemFormSet(instance=order)
    return render(request, 'order_edit.html', {
        'order': order,
        'formset': formset,
    })

Step 6ファイルアップロード

Djangoでファイルアップロードを実装するには、フォーム・ビュー・テンプレート・設定のそれぞれで対応が必要です。

settings.pyの設定

settings.py
# メディアファイルの設定
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'

モデルにファイルフィールドを定義

models.py
from django.db import models

class Document(models.Model):
    title = models.CharField('タイトル', max_length=200)
    file = models.FileField('ファイル', upload_to='documents/%Y/%m/')
    uploaded_at = models.DateTimeField('アップロード日時', auto_now_add=True)

    def __str__(self):
        return self.title

class Profile(models.Model):
    user = models.OneToOneField('auth.User', on_delete=models.CASCADE)
    avatar = models.ImageField(
        'アバター',
        upload_to='avatars/',
        blank=True,
        null=True
    )
    # ImageFieldを使うにはPillowが必要
    # pip install Pillow

アップロードフォーム

forms.py
from django import forms
from .models import Document

class DocumentForm(forms.ModelForm):
    class Meta:
        model = Document
        fields = ['title', 'file']

    def clean_file(self):
        file = self.cleaned_data.get('file')
        if file:
            # ファイルサイズを制限(5MB)
            if file.size > 5 * 1024 * 1024:
                raise forms.ValidationError('ファイルサイズは5MB以下にしてください。')
            # 許可する拡張子を制限
            allowed_extensions = ['.pdf', '.docx', '.xlsx', '.csv']
            import os
            ext = os.path.splitext(file.name)[1].lower()
            if ext not in allowed_extensions:
                raise forms.ValidationError(
                    f'許可されていないファイル形式です。対応形式: {", ".join(allowed_extensions)}'
                )
        return file

ビューでファイルを受け取る

views.py
def document_upload(request):
    if request.method == 'POST':
        form = DocumentForm(request.POST, request.FILES)  # request.FILESが必要
        if form.is_valid():
            form.save()
            return redirect('document_list')
    else:
        form = DocumentForm()
    return render(request, 'document_upload.html', {'form': form})
request.FILESを忘れない
ファイルアップロードでは、フォームのインスタンス化時にrequest.FILESを第2引数として渡す必要があります。これを忘れると、ファイルデータがフォームに渡されません。

テンプレート(enctype属性が必須)

templates/document_upload.html
<form method="post" enctype="multipart/form-data">
    {% csrf_token %}
    {{ form.as_p }}
    <button type="submit">アップロード</button>
</form>
enctype="multipart/form-data"は必須
ファイルアップロードのフォームではenctype="multipart/form-data"を指定しないと、ファイルデータがサーバーに送信されません。通常のフォームのデフォルトはapplication/x-www-form-urlencodedです。

開発環境でメディアファイルを配信する

urls.py
from django.conf import settings
from django.conf.urls.static import static
from django.urls import path, include

urlpatterns = [
    # ... 他のURL
]

# 開発環境でメディアファイルを配信
if settings.DEBUG:
    urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
本番環境でのファイル配信
本番環境では、NginxやApacheでメディアファイルを直接配信するか、Amazon S3などのオブジェクトストレージを利用します。django-storagesパッケージを使えばS3との連携が簡単に設定できます。

まとめDjangoフォームのポイント

この記事で学んだこと

  • forms.Form でHTMLフォームの生成・バリデーション・エラー表示を一元管理できる
  • ModelForm でモデルから自動的にフォームを生成し、CRUD操作を簡潔に書ける
  • clean_<fieldname> でフィールド単位、clean() でフォーム全体のバリデーションを実装できる
  • ウィジェットの attrs でCSSクラスやHTML属性を自在にカスタマイズできる
  • FormSet / ModelFormSet / InlineFormSet で複数フォームを一括処理できる
  • ファイルアップロードでは request.FILESenctype="multipart/form-data" が必須