Django Models and the ORM
A practical tour of Django's ORM — defining models, common field types, makemigrations and migrate, QuerySet filtering, foreign keys, and simple aggregations.
What you'll learn
- ✓How to define a model as a Python class
- ✓The most common field types and what they map to
- ✓How makemigrations and migrate evolve your schema
- ✓How to query the database with QuerySets — filter, get, exclude, order_by
- ✓How foreign keys model relationships
- ✓How to run simple aggregations like count, sum, and average
Prerequisites
- •A scaffolded Django project — see Install Django and Start Your First Project
- •Comfortable with Python classes
Models are where Django stores your data. They’re also where most of your business logic eventually lives. The ORM — object-relational mapper — turns Python classes into database tables and Python expressions into SQL. You write Post.objects.filter(published=True); Django writes the SELECT ... WHERE published = true.
This post walks through models and the ORM in enough depth that you can build a real schema. If your project isn’t set up yet, follow Install Django and Start Your First Project first.
Defining a model
Open blog/models.py and replace the contents:
# blog/models.py
from django.db import models
class Post(models.Model):
title = models.CharField(max_length=200)
slug = models.SlugField(unique=True)
body = models.TextField()
published = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
# Shown in the admin and in the shell — make it useful
return self.title
A few things worth noticing:
- The class inherits from
models.Model. That’s what makes it an ORM-managed table. - Each class attribute is a field, which becomes a column.
- Django adds an auto-incrementing integer primary key called
idautomatically. __str__controls how instances appear in the admin and the REPL.
Common field types
The full list is long; these are the ones you’ll reach for daily.
# Strings
title = models.CharField(max_length=200) # short, indexed-friendly
body = models.TextField() # unbounded
slug = models.SlugField(unique=True) # URL-safe identifier
email = models.EmailField() # CharField with validation
# Numbers
price = models.DecimalField(max_digits=10, decimal_places=2)
views = models.PositiveIntegerField(default=0)
rating = models.FloatField(null=True, blank=True)
# Dates and times
published_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True) # set on create
updated_at = models.DateTimeField(auto_now=True) # set on every save
# Booleans
is_featured = models.BooleanField(default=False)
# Files
cover = models.ImageField(upload_to="covers/", blank=True)
attachment = models.FileField(upload_to="docs/", blank=True)
# Choices
STATUS_CHOICES = [("draft", "Draft"), ("published", "Published")]
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="draft")
Two flags appear constantly: null controls whether the database allows NULL; blank controls whether the form allows an empty value. They’re independent. For string fields, prefer blank=True and not null=True — Django convention is to store an empty string rather than NULL.
Migrations
A model definition is just Python — the database doesn’t know about it yet. Django uses migrations to bring the two into sync.
Generate a migration from your current model state:
python manage.py makemigrations
You’ll see something like:
Migrations for 'blog':
blog/migrations/0001_initial.py
+ Create model Post
Open the generated file if you’re curious — it’s regular Python describing the schema change. Then apply it:
python manage.py migrate
migrate runs every unapplied migration across every app. You’ll see Django’s built-in tables created (users, sessions, content types) along with your Post table.
The workflow you’ll repeat forever:
- Edit
models.py. python manage.py makemigrations.python manage.py migrate.
Commit migration files to git. They’re part of your project’s history, and they’re how other developers and your production database catch up.
The shell
Django’s interactive shell loads your project’s settings and models. It’s the fastest way to learn the ORM:
python manage.py shell
Inside, import and play:
>>> from blog.models import Post
>>> Post.objects.create(title="Hello world", slug="hello-world", body="First post.")
<Post: Hello world>
>>> Post.objects.count()
1
>>> Post.objects.all()
<QuerySet [<Post: Hello world>]>
QuerySets
The objects attribute on every model is a manager. Calling methods on it returns a QuerySet — a lazy, chainable description of a database query. The query doesn’t run until you iterate, slice, or evaluate it.
filter and exclude
# Posts that are published
Post.objects.filter(published=True)
# Posts that are NOT published
Post.objects.exclude(published=True)
# Combine — published posts whose title contains "django"
Post.objects.filter(published=True, title__icontains="django")
The double-underscore syntax (title__icontains) is how Django expresses field lookups. Useful ones:
Post.objects.filter(title__exact="Hello") # exact match
Post.objects.filter(title__iexact="hello") # case-insensitive
Post.objects.filter(title__contains="Django") # substring
Post.objects.filter(title__icontains="django") # case-insensitive substring
Post.objects.filter(title__startswith="The")
Post.objects.filter(views__gt=100) # greater than
Post.objects.filter(views__gte=100) # >=
Post.objects.filter(views__lt=10) # <
Post.objects.filter(created_at__year=2026)
Post.objects.filter(id__in=[1, 2, 3])
Post.objects.filter(body__isnull=False)
get
get returns exactly one object — or raises. Use it when you expect one row:
post = Post.objects.get(slug="hello-world")
If no row matches, Post.DoesNotExist. If more than one matches, Post.MultipleObjectsReturned. Wrap it in a try or use get_object_or_404 in views.
order_by
# Newest first
Post.objects.order_by("-created_at")
# Multiple fields — published descending, then title ascending
Post.objects.order_by("-published", "title")
The - prefix flips the direction.
Slicing
QuerySets slice like lists — and the slice becomes a LIMIT/OFFSET:
Post.objects.order_by("-created_at")[:10] # first 10
Post.objects.order_by("-created_at")[10:20] # next 10
Chaining
QuerySets are chainable and lazy. Build up complex queries without hitting the database until the last moment:
recent_published = (
Post.objects
.filter(published=True)
.filter(created_at__year=2026)
.exclude(title__icontains="draft")
.order_by("-created_at")
)
# No SQL has run yet. The next line runs one query.
for post in recent_published[:5]:
print(post.title)
Try it yourself. In the shell, create three posts with different titles and published flags. Run Post.objects.filter(published=True).count(). Then experiment with title__icontains and order_by. Add print(Post.objects.all().query) to see the exact SQL Django generates — it’s a great way to build intuition.
Foreign keys
Real applications have relationships. Add an author:
# blog/models.py
from django.contrib.auth.models import User
class Post(models.Model):
author = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="posts",
)
title = models.CharField(max_length=200)
# ... (other fields)
Run makemigrations and migrate again. Django generates the SQL to add the column and the foreign-key constraint.
Two arguments deserve attention:
on_delete— what happens when the referenced row is deleted.CASCADEdeletes the post too. Other options:PROTECT(block the delete),SET_NULL(requiresnull=True),SET_DEFAULT.related_name— what to call the reverse relation. Nowuser.posts.all()returns every post that user wrote.
Querying across the relationship uses the same double-underscore syntax:
# All posts by users whose username starts with "a"
Post.objects.filter(author__username__startswith="a")
# All published posts for one user
user.posts.filter(published=True)
Avoiding the N+1 problem
The ORM’s biggest footgun. This code runs one query per iteration:
for post in Post.objects.all():
print(post.author.username) # one extra query per post
Fix it with select_related (for ForeignKey/OneToOne) or prefetch_related (for many-to-many and reverse FK):
for post in Post.objects.select_related("author"):
print(post.author.username) # one query total
Get in the habit of using select_related in any view that loops over related objects. Your database will thank you.
Aggregations
Group-level math: counts, sums, averages, minima, maxima.
from django.db.models import Count, Avg, Sum, Max, Min
# Total number of published posts
Post.objects.filter(published=True).count()
# Per-author post count
from django.contrib.auth.models import User
User.objects.annotate(post_count=Count("posts")).order_by("-post_count")
# Average views across all posts
Post.objects.aggregate(Avg("views"))
# -> {'views__avg': 42.3}
# Sum of views in 2026
Post.objects.filter(created_at__year=2026).aggregate(total=Sum("views"))
# -> {'total': 12345}
aggregate returns a dict and ends the QuerySet. annotate adds a computed column to each row and keeps the QuerySet going.
A complete model
Putting it together:
# blog/models.py
from django.contrib.auth.models import User
from django.db import models
from django.urls import reverse
class Post(models.Model):
STATUS_CHOICES = [("draft", "Draft"), ("published", "Published")]
author = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="posts"
)
title = models.CharField(max_length=200)
slug = models.SlugField(unique=True)
body = models.TextField()
status = models.CharField(
max_length=20, choices=STATUS_CHOICES, default="draft"
)
views = models.PositiveIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-created_at"]
indexes = [models.Index(fields=["status", "-created_at"])]
def __str__(self):
return self.title
def get_absolute_url(self):
# Reverse the named URL pattern so templates can use post.get_absolute_url
return reverse("post_detail", args=[self.slug])
The Meta inner class is where you put model-wide configuration — default ordering, indexes, unique constraints, the database table name.
Try it yourself. Add a Category model with name and slug fields, and link it to Post via a ForeignKey with related_name="posts". Run makemigrations and migrate. Then in the shell, create a category and assign it to a post. Query category.posts.all() to see the reverse lookup work.
Recap
You now know:
- A model is a Python class that maps to a database table
- Common field types cover strings, numbers, dates, booleans, files, and choices
makemigrationsgenerates a migration;migrateapplies it- QuerySets are lazy and chainable —
filter,exclude,get,order_by, slicing - Foreign keys model relationships, with
on_deleteandrelated_nameas key arguments select_relatedandprefetch_relatedfix the N+1 query problemaggregateandannotatehandle counts, sums, averages, and per-row computations
Next steps
Models give you data. The next post turns that data into pages — Django Views, URLs, and Templates shows how to wire models to views, render templates, and use template inheritance.
Related: What Is Django?, The Django Admin.
Questions or feedback? Email codeloomdevv@gmail.com.