Django基礎 #3 Models と ORM 基礎

読了 7分

#2 プロジェクトのセットアップ で空の blog アプリを作りました。今回はその中の models.pyDB の形 を描き、ORM でデータを扱う最初の一歩をつかみます。SQL を 1 行も直接書きません — それが ORM の約束です。

Model の一行定義 #

Django の ModelDB テーブルにマッピングされる Python クラス です。クラス 1 つ = テーブル 1 つ、クラスの属性 = カラム。

blog/models.py
from django.db import models


class Post(models.Model):
    title = models.CharField(max_length=200)
    content = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    is_published = models.BooleanField(default=False)

    def __str__(self) -> str:
        return self.title

このクラス 1 つで次の SQL が生成されます (直接書かなくていいです — マイグレーションが作ります)。

自動生成される SQL (参考)
CREATE TABLE "blog_post" (
    "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
    "title" varchar(200) NOT NULL,
    "content" text NOT NULL,
    "created_at" datetime NOT NULL,
    "updated_at" datetime NOT NULL,
    "is_published" bool NOT NULL
);

テーブル名は <app>_<モデル> (小文字) — blog_postid は自動生成される PK です。

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

blog/models.py — フィールドカタログ
from django.db import models


class FieldDemo(models.Model):
    # 文字列
    name = models.CharField(max_length=100)              # VARCHAR
    description = models.TextField()                     # TEXT (長さ制限なし)
    slug = models.SlugField(unique=True)                 # URL フレンドリーな文字列

    # 数値
    views = models.IntegerField(default=0)
    price = models.DecimalField(max_digits=10, decimal_places=2)
    rating = models.FloatField()

    # ブール
    is_active = models.BooleanField(default=True)

    # 日付/時刻
    published_at = models.DateField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)  # 生成時に自動
    updated_at = models.DateTimeField(auto_now=True)      # 保存ごとに自動

    # その他
    email = models.EmailField()
    website = models.URLField(blank=True)
    image = models.ImageField(upload_to="images/", blank=True)
    file = models.FileField(upload_to="files/", blank=True)
    uuid = models.UUIDField()

よく見られるオプション:

  • null=True — DB レベルで NULL を許可
  • blank=True — フォーム検証で空の値を許可
  • default=... — デフォルト値
  • unique=True — 一意制約
  • choices=[...] — 決まった値だけ許可 (Enum に近い)
  • db_index=True — インデックス

null vs blank 混乱しやすいポイント — nullDBblankフォーム。文字列フィールドには null=True をあまり使いません (空文字 "" にしておくのがコンベンション)。

choices — Enum 風 #

choices パターン
class Post(models.Model):
    class Status(models.TextChoices):
        DRAFT = "draft", "草稿"
        PUBLISHED = "published", "公開済み"
        ARCHIVED = "archived", "保管済み"

    title = models.CharField(max_length=200)
    status = models.CharField(
        max_length=20,
        choices=Status.choices,
        default=Status.DRAFT,
    )

TextChoices (または IntegerChoices) が Python 3 の Enum のように動作しながら、DB 値 + 人が読むラベル を一度に定義します。フォーム / Admin で自動的に select オプションになります。

Migration — モデル変更を SQL に #

モデルを作ったり変えたりしたら 2 ステップ:

マイグレーションの流れ
uv run python manage.py makemigrations blog
uv run python manage.py migrate

各ステップの仕事:

  1. makemigrations — モデルと現在のマイグレーション状態を比較し、差分を マイグレーションファイル として保存 (blog/migrations/0001_initial.py のような)
  2. migrate — マイグレーションファイルを 実際の DB に適用
blog/migrations/0001_initial.py (自動生成)
class Migration(migrations.Migration):
    initial = True
    dependencies = []
    operations = [
        migrations.CreateModel(
            name="Post",
            fields=[
                ("id", models.AutoField(primary_key=True)),
                ("title", models.CharField(max_length=200)),
                ("content", models.TextField()),
                ("created_at", models.DateTimeField(auto_now_add=True)),
                ("updated_at", models.DateTimeField(auto_now=True)),
                ("is_published", models.BooleanField(default=False)),
            ],
        ),
    ]

マイグレーションファイルは 必ず git にコミット します。チームメンバーが同じスキーマを再現できる必要があります。

よく使うマイグレーションコマンド #

よく使うコマンド
# 適用状態の確認
uv run python manage.py showmigrations

# 特定のマイグレーションの SQL プレビュー
uv run python manage.py sqlmigrate blog 0001

# 巻き戻し (例: 0001 以前に)
uv run python manage.py migrate blog zero

関係 — ForeignKey、ManyToMany、OneToOne #

ブログのドメインによくある関係たち:

blog/models.py — 関係
from django.conf import settings
from django.db import models


class Tag(models.Model):
    name = models.CharField(max_length=50, unique=True)
    slug = models.SlugField(unique=True)

    def __str__(self) -> str:
        return self.name


class Post(models.Model):
    author = models.ForeignKey(
        settings.AUTH_USER_MODEL,                # ユーザーモデル
        on_delete=models.CASCADE,
        related_name="posts",
    )
    tags = models.ManyToManyField(Tag, related_name="posts", blank=True)
    title = models.CharField(max_length=200)
    content = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)

    def __str__(self) -> str:
        return self.title


class Comment(models.Model):
    post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name="comments")
    author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
    body = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)


class Profile(models.Model):
    user = models.OneToOneField(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="profile",
    )
    bio = models.TextField(blank=True)

3 種類の関係:

  • ForeignKey — N:1。Post -> User (1 人のユーザーが複数の記事)
  • ManyToManyField — N:N。Post <-> Tag (1 つの記事に複数のタグ、1 つのタグに複数の記事)
  • OneToOneField — 1:1。Profile <-> User

on_delete オプション #

ForeignKey は 親が削除されたとき子をどうするか を必ず指定する必要があります。

オプション動作
CASCADE親削除時に子も削除 (もっとも一般的)
PROTECT子があれば親の削除をブロック
SET_NULL子の FK を NULL に (null=True が必要)
SET_DEFAULTデフォルト値に (default= が必要)
DO_NOTHINGDB に任せる (通常は使わない)

related_name — 逆参照の名前 #

posts = user.posts.all()         # related_name="posts"
comments = post.comments.all()   # related_name="comments"

related_name を与えないと post_setcomment_set のようなデフォルト名になります。明示的に与えるほうが可読性が上がります。

settings.AUTH_USER_MODEL を使う理由 #

from django.contrib.auth.models import User を直接 import しても動作はしますが、後でカスタム User モデルに変えるとき、すべての import を直さなければなりません。settings.AUTH_USER_MODEL にしておくと一度に差し替えられます。コンベンション です。

QuerySet — Django ORM の核心 #

モデルが作られたら、いよいよ ORM でデータを扱います。QuerySetDB クエリを表現する Python オブジェクト — lazy に動作します (実際の呼び出し前まで SQL は出ません)。

shell に入る
uv run python manage.py shell

Create — 作る #

オブジェクトの生成
from blog.models import Post, Tag
from django.contrib.auth import get_user_model

User = get_user_model()
author = User.objects.create_user(username="curtis", password="secret123")

# 方法 1: インスタンス → save
post = Post(author=author, title="最初の記事", content="こんにちは!")
post.save()

# 方法 2: objects.create (一発)
post = Post.objects.create(author=author, title="2番目の記事", content="...")

# Tag M2M 追加
tag1 = Tag.objects.create(name="django", slug="django")
tag2 = Tag.objects.create(name="python", slug="python")
post.tags.add(tag1, tag2)

Read — 取得 #

基本の取得
# 全件
Post.objects.all()                        # <QuerySet [<Post: 最初の記事>, ...]>

# PK で 1 件 (なければ DoesNotExist)
Post.objects.get(pk=1)
Post.objects.get(id=1)

# 条件
Post.objects.filter(is_published=True)
Post.objects.exclude(is_published=False)

# 最初 / 最後 / カウント
Post.objects.first()
Post.objects.last()
Post.objects.count()

# ソート
Post.objects.order_by("-created_at")      # 新しい順
Post.objects.order_by("title")            # 五十音順

# スライシング (LIMIT/OFFSET)
Post.objects.all()[:10]                   # 最初の 10 件
Post.objects.all()[10:20]                 # 次の 10 件

# 存在確認 (もっとも速い)
Post.objects.filter(author=author).exists()

Lookup — __ で条件を作る #

よく使う lookup
# テキスト
Post.objects.filter(title__icontains="django")    # 大文字小文字無視の部分一致
Post.objects.filter(title__startswith="こんにちは")
Post.objects.filter(title__endswith="!")

# 比較
Post.objects.filter(views__gt=100)                 # > 100
Post.objects.filter(views__gte=100)                # >= 100
Post.objects.filter(views__lt=10)                  # < 10
Post.objects.filter(views__lte=10)                 # <= 10

# 包含
Post.objects.filter(id__in=[1, 2, 3])
Post.objects.filter(id__range=(1, 100))

# NULL
Post.objects.filter(published_at__isnull=True)

# 日付
Post.objects.filter(created_at__year=2026)
Post.objects.filter(created_at__date="2026-04-14")
Post.objects.filter(created_at__gte="2026-01-01")

# 関係を辿る (FK のドット線)
Post.objects.filter(author__username="curtis")
Post.objects.filter(tags__name="django")

__ (二重アンダースコア) が Django ORM の 共通文法 です。フィールド + lookup、または関係 + フィールド + lookup の形式。

Q オブジェクト — OR / 複雑な条件 #

filter(...) のキーワード引数はすべて AND です。OR や複雑な条件は Q で:

Q オブジェクト
from django.db.models import Q

# OR
Post.objects.filter(Q(title__icontains="django") | Q(content__icontains="django"))

# AND + OR の混合
Post.objects.filter(
    Q(is_published=True) & (Q(title__icontains="django") | Q(tags__name="django"))
)

# NOT
Post.objects.filter(~Q(author__username="curtis"))

Update / Delete #

修正 / 削除
# 単件修正
post = Post.objects.get(pk=1)
post.title = "修正されたタイトル"
post.save()

# 一括修正 (SQL UPDATE 1 回)
Post.objects.filter(is_published=False).update(is_published=True)

# 単件削除
post.delete()

# 一括削除
Post.objects.filter(created_at__lt="2020-01-01").delete()

update(...)save() を経由しませんauto_nowsignals のようなものが動きません。トレードオフを知って使ってください。詳しくは 中級 #2

Lazy QuerySet — クエリはいつ出るのか #

lazy 動作
qs = Post.objects.filter(is_published=True)   # まだ SQL は出ない
qs = qs.order_by("-created_at")               # まだ出ない
qs = qs[:10]                                   # まだ出ない

for post in qs:                                # ← ここで SQL が 1 回出る
    print(post.title)

QuerySet は イテレーション、スライシング、len()bool()list()count() など が呼ばれたときに初めて SQL を発射します。だからメソッドチェーンを自由に重ねられます。これは ORM 最適化の出発点です — 上級 #3 で詳しく。

Meta — モデル設定 #

Meta クラス
class Post(models.Model):
    title = models.CharField(max_length=200)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["-created_at"]               # デフォルトのソート
        verbose_name = "記事"                     # Admin の表示名
        verbose_name_plural = "記事一覧"
        indexes = [
            models.Index(fields=["-created_at"]),
        ]
        constraints = [
            models.UniqueConstraint(
                fields=["author", "title"],
                name="unique_author_title",
            ),
        ]

Meta がモデルの DB / 表示関連の設定 を集めます。ordering を一度設定しておくと .all() に自動で適用されます。

まとめ #

今回つかんだもの:

  • Model = Python クラス = DB テーブル、属性 = カラム
  • フィールド — CharFieldTextFieldDateTimeFieldBooleanFieldSlugField、…
  • null (DB) と blank (フォーム) は別物
  • TextChoices で Enum 風
  • makemigrationsmigrate の 2 ステップ、マイグレーションファイルは git コミット
  • 関係 — ForeignKey (N:1)、ManyToManyField (N:N)、OneToOneField (1:1)
  • on_delete は ForeignKey の必須、通常は CASCADE
  • related_name で逆参照名を明示
  • settings.AUTH_USER_MODEL のコンベンション
  • QuerySet は lazy — イテレーション / スライシング / exists() 時に SQL を発射
  • lookup __icontains__gt__in__year__isnull、…
  • Q オブジェクトで OR / NOT
  • objects.createsaveupdate()delete()
  • Meta で ordering / index / constraint

次回(#4 URL と Views)ではこのモデルで作ったデータを URL と view 関数 で公開し、render で最初の HTML レスポンスを作ってみます。ORM のより深い話 (select_related、prefetch_related、annotate) は 中級 #2 ORM 中級 で続きます。

X