Skip to content
C Codeloom
Django

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.

·8 min read · By Yash Kesharwani
Intermediate 12 min read

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 id automatically.
  • __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:

  1. Edit models.py.
  2. python manage.py makemigrations.
  3. 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. CASCADE deletes the post too. Other options: PROTECT (block the delete), SET_NULL (requires null=True), SET_DEFAULT.
  • related_name — what to call the reverse relation. Now user.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
  • makemigrations generates a migration; migrate applies it
  • QuerySets are lazy and chainable — filter, exclude, get, order_by, slicing
  • Foreign keys model relationships, with on_delete and related_name as key arguments
  • select_related and prefetch_related fix the N+1 query problem
  • aggregate and annotate handle 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.