Django Basics #3: Models and ORM Basics
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.
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.titleThis single class produces the following SQL — you don’t have to write it yourself; migrations create it.
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 #
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— allowNULLat the DB levelblank=True— allow empty values in form validationdefault=...— default valueunique=True— uniqueness constraintchoices=[...]— only allow predefined values (Enum-like)db_index=True— index
nullvsblankis a common trip wire —nullis DB,blankis form. For string fields,null=Trueis rarely used (the convention is to leave it as the empty string"").
choices — Enum-like
#
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:
uv run python manage.py makemigrations blog
uv run python manage.py migrateWhat each step does:
makemigrations— Compares the models with the current migration state and saves the diff to a migration file (e.g.blog/migrations/0001_initial.py)migrate— Applies the migration files to the actual DB
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 #
# 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 zeroRelations — ForeignKey, ManyToMany, OneToOne #
Common relations in a blog domain:
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.
| Option | Behavior |
|---|---|
CASCADE | Delete child too when parent is deleted (most common) |
PROTECT | Block parent deletion if children exist |
SET_NULL | Set child’s FK to NULL (requires null=True) |
SET_DEFAULT | Use the default value (requires default=) |
DO_NOTHING | Leave 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).
uv run python manage.py shellCreate #
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 #
# 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 __
#
# 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:
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 #
# 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 #
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 #
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) andblank(form) are differentTextChoicesfor Enum-like values- Two steps
makemigrations→migrate, commit migration files to git - Relations —
ForeignKey(N:1),ManyToManyField(N:N),OneToOneField(1:1) on_deleteis required on ForeignKey, usuallyCASCADE- Use
related_nameto name the reverse accessor explicitly settings.AUTH_USER_MODELconvention- QuerySet is lazy — SQL fires on iteration / slicing /
exists() - Lookups —
__icontains,__gt,__in,__year,__isnull, … Qobjects for OR / NOTobjects.create,save,update(),delete()Metafor 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.