Django Basics #3: Models and ORM Basics

7 min read

In #2 Project setup you created an empty blog app. This post draws the shape of your DB in its models.py and gets you started handling data with the ORM. You won’t write a single line of SQL by hand — that’s the promise of an ORM.

Model in one line #

A Django Model is a Python class that maps to a DB table. One class = one table, class attributes = columns.

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

This single class produces the following SQL — you don’t have to write it yourself; migrations create it.

Auto-generated SQL (for reference)
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
);

The table name is <app>_<model> (lowercase) — blog_post. The id is an auto-generated PK.

Common field types #

blog/models.py — field catalog
from django.db import models


class FieldDemo(models.Model):
    # Strings
    name = models.CharField(max_length=100)              # VARCHAR
    description = models.TextField()                     # TEXT (no length limit)
    slug = models.SlugField(unique=True)                 # URL-friendly string

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

    # Boolean
    is_active = models.BooleanField(default=True)

    # Date / time
    published_at = models.DateField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)  # auto on create
    updated_at = models.DateTimeField(auto_now=True)      # auto on every save

    # Misc
    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()

Options you’ll see often:

  • null=True — allow NULL at the DB level
  • blank=True — allow empty values in form validation
  • default=... — default value
  • unique=True — uniqueness constraint
  • choices=[...] — only allow predefined values (Enum-like)
  • db_index=True — index

null vs blank is a common trip wire — null is DB, blank is form. For string fields, null=True is rarely used (the convention is to leave it as the empty string "").

choices — Enum-like #

choices pattern
class Post(models.Model):
    class Status(models.TextChoices):
        DRAFT = "draft", "Draft"
        PUBLISHED = "published", "Published"
        ARCHIVED = "archived", "Archived"

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

TextChoices (or IntegerChoices) acts much like Python 3’s Enum, defining the DB value + human-readable label at once. It becomes a select option in forms / Admin automatically.

Migration — model changes into SQL #

When you create or change a model, two steps:

Migration flow
uv run python manage.py makemigrations blog
uv run python manage.py migrate

What each step does:

  1. makemigrations — Compares the models with the current migration state and saves the diff to a migration file (e.g. blog/migrations/0001_initial.py)
  2. migrateApplies the migration files to the actual DB
blog/migrations/0001_initial.py (auto-generated)
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)),
            ],
        ),
    ]

Always commit migration files to git. Teammates need to be able to reproduce the same schema.

Frequently used migration commands #

Common commands
# Check applied state
uv run python manage.py showmigrations

# Preview SQL for a specific migration
uv run python manage.py sqlmigrate blog 0001

# Roll back (e.g. before 0001)
uv run python manage.py migrate blog zero

Relations — ForeignKey, ManyToMany, OneToOne #

Common relations in a blog domain:

blog/models.py — relations
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,                # 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)

Three relations:

  • ForeignKey — N:1. Post -> User (one user, many posts)
  • ManyToManyField — N:N. Post <-> Tag (a post has many tags, a tag has many posts)
  • OneToOneField — 1:1. Profile <-> User

on_delete options #

ForeignKey must specify what happens when the parent is deleted.

OptionBehavior
CASCADEDelete child too when parent is deleted (most common)
PROTECTBlock parent deletion if children exist
SET_NULLSet child’s FK to NULL (requires null=True)
SET_DEFAULTUse the default value (requires default=)
DO_NOTHINGLeave it to the DB (typically not used)

related_name — naming the reverse accessor #

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

Without related_name, you get default names like post_set, comment_set. Specifying it explicitly is better for readability.

Why settings.AUTH_USER_MODEL #

Importing from django.contrib.auth.models import User directly works, but later if you switch to a custom User model, you must fix every import. Using settings.AUTH_USER_MODEL lets you swap once. It’s the convention.

QuerySet — the heart of Django ORM #

Once the models are made, you handle data through the ORM. QuerySet is a Python object that represents a DB query — it’s lazy (no SQL fires until it’s actually evaluated).

Enter the shell
uv run python manage.py shell

Create #

Create objects
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")

# Method 1: instance → save
post = Post(author=author, title="First post", content="Hi!")
post.save()

# Method 2: objects.create (in one shot)
post = Post.objects.create(author=author, title="Second post", content="...")

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

Read #

Basic queries
# All
Post.objects.all()                        # <QuerySet [<Post: First post>, ...]>

# One by PK (DoesNotExist if missing)
Post.objects.get(pk=1)
Post.objects.get(id=1)

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

# First / last / count
Post.objects.first()
Post.objects.last()
Post.objects.count()

# Sort
Post.objects.order_by("-created_at")      # newest first
Post.objects.order_by("title")            # alphabetical

# Slicing (LIMIT/OFFSET)
Post.objects.all()[:10]                   # first 10
Post.objects.all()[10:20]                 # next 10

# Existence (the fastest)
Post.objects.filter(author=author).exists()

Lookup — building conditions with __ #

Common lookups
# Text
Post.objects.filter(title__icontains="django")    # case-insensitive contains
Post.objects.filter(title__startswith="Hello")
Post.objects.filter(title__endswith="!")

# Comparison
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

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

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

# Date
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")

# Following relations (FK dotted path)
Post.objects.filter(author__username="curtis")
Post.objects.filter(tags__name="django")

__ (double underscore) is the common syntax of Django ORM. Field + lookup, or relation + field + lookup.

Q objects — OR / complex conditions #

The keyword arguments to filter(...) are combined with AND. For OR or more complex conditions, use Q:

Q objects
from django.db.models import Q

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

# AND + OR mix
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 #

Update / delete
# Single update
post = Post.objects.get(pk=1)
post.title = "Updated title"
post.save()

# Bulk update (one SQL UPDATE)
Post.objects.filter(is_published=False).update(is_published=True)

# Single delete
post.delete()

# Bulk delete
Post.objects.filter(created_at__lt="2020-01-01").delete()

update(...) bypasses save()auto_now, signals, and the like don’t fire. Know the trade-off before using it. Details in Intermediate #2.

Lazy QuerySet — when does the query fire #

Lazy behavior
qs = Post.objects.filter(is_published=True)   # no SQL yet
qs = qs.order_by("-created_at")               # still no SQL
qs = qs[:10]                                   # still no SQL

for post in qs:                                # ← SQL fires here, once
    print(post.title)

A QuerySet only fires SQL when it’s iterated, sliced, or evaluated by len(), bool(), list(), count(), etc. That’s why method chaining is free. This is the starting point of ORM optimization — covered in detail in Advanced #3.

Meta — model settings #

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

    class Meta:
        ordering = ["-created_at"]               # default ordering
        verbose_name = "post"                     # name shown in Admin
        verbose_name_plural = "posts"
        indexes = [
            models.Index(fields=["-created_at"]),
        ]
        constraints = [
            models.UniqueConstraint(
                fields=["author", "title"],
                name="unique_author_title",
            ),
        ]

Meta collects the model’s DB / display-related settings. Setting ordering once applies it automatically to .all().

Recap #

What this post nailed down:

  • Model = Python class = DB table, attributes = columns
  • Fields — CharField, TextField, DateTimeField, BooleanField, SlugField, …
  • null (DB) and blank (form) are different
  • TextChoices for Enum-like values
  • Two steps makemigrationsmigrate, commit migration files to git
  • Relations — ForeignKey (N:1), ManyToManyField (N:N), OneToOneField (1:1)
  • on_delete is required on ForeignKey, usually CASCADE
  • Use related_name to name the reverse accessor explicitly
  • settings.AUTH_USER_MODEL convention
  • QuerySet is lazy — SQL fires on iteration / slicing / exists()
  • Lookups — __icontains, __gt, __in, __year, __isnull, …
  • Q objects for OR / NOT
  • objects.create, save, update(), delete()
  • Meta for ordering / index / constraint

In the next post (#4 URL and Views), you’ll expose the data from this model through URLs and view functions, and produce your first HTML response with render. The deeper ORM story (select_related, prefetch_related, annotate) continues in Intermediate #2 ORM advanced.

X