Skip to main content

🗄️ Data Modeling in Django

Models are the heart of every Django app. A single Python class becomes a database table, a validation layer, an admin screen, and a rich query API — all at once. This lesson teaches you to design that class well: the right fields, the right relationships, and the migrations that turn your intent into a real schema.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Define models with appropriate field types and field options
  • Model one-to-many, many-to-many, and one-to-one relationships correctly
  • Use the migration system to create and evolve your database schema
  • Add behavior with model methods, the Meta class, and custom managers
  • Query data fluently with the QuerySet API, including field lookups and Q objects

Estimated Time: 40–50 minutes  •  Difficulty: Intermediate

Hands-on: Model a small blog domain, run migrations, and query it in the shell.

In This Lesson

What Is a Model?

A Django model is a Python class that subclasses django.db.models.Model. Each model maps to a database table, and each class attribute maps to a column. You describe your data once, in Python, and Django takes care of creating the table, generating a query API, and wiring the model into the admin and forms.

📐 The blueprint analogy: A model is like an architect's blueprint. The blueprint specifies every room, wall, and dimension before anyone pours concrete. Your model specifies every field, type, and constraint before Django creates the table. Change the blueprint and you commission a renovation — in Django, that renovation is a migration.

Here is a minimal but realistic model:

from django.db import models

class Book(models.Model):
    title = models.CharField(max_length=200)
    author = models.CharField(max_length=100)
    description = models.TextField(blank=True)
    publication_date = models.DateField()
    price = models.DecimalField(max_digits=6, decimal_places=2)
    is_published = models.BooleanField(default=True)

    def __str__(self):
        return self.title

When Django processes this class it will:

  1. Create a table (by default named <app>_book).
  2. Add a column for each field.
  3. Add an auto-incrementing id primary key automatically.
  4. Expose a query API through Book.objects.

📖 Why __str__ matters

The __str__ method decides how an object appears in the admin, the shell, and templates. Without it you'll see unhelpful lines like Book object (1). Always define one.

A Python model class becomes a database table The Book model class on the left maps to a database table on the right, where each class attribute becomes a column and each saved instance becomes a row. class Book(models.Model) title = CharField(...) author = CharField(...) price = DecimalField(...) is_published = Boolean... Python — you write this migrate app_book (table) id | title | author | price | is_published 1 | Dune | Herbert| 12.99 | true 2 | 1984 | Orwell | 9.50 | true SQL — Django builds this
Figure 1 — One model class defines the table's columns; each saved instance becomes a row.

Field Types & Options

Django offers a field type for almost every kind of data. Choosing the right one gives you validation and the correct database column for free.

FieldStoresExample
CharFieldShort text (needs max_length)models.CharField(max_length=100)
TextFieldLong, unbounded textmodels.TextField()
IntegerFieldWhole numbersmodels.IntegerField()
DecimalFieldExact decimals (money!)models.DecimalField(max_digits=6, decimal_places=2)
BooleanFieldTrue / Falsemodels.BooleanField(default=True)
DateTimeFieldDate + timemodels.DateTimeField(auto_now_add=True)
EmailFieldValidated emailmodels.EmailField()
SlugFieldURL-safe labelsmodels.SlugField(unique=True)
ImageFieldUploaded imagesmodels.ImageField(upload_to="covers/")
JSONFieldStructured JSONmodels.JSONField(default=dict)

⚠️ Never use FloatField for money

Floating-point numbers can't represent 0.1 exactly, so prices drift by fractions of a cent over time. Use DecimalField for any currency amount.

Common field options

Almost every field accepts a shared set of options that control validation and database behavior:

OptionEffect
null=TrueAllows NULL in the database
blank=TrueAllows the field to be empty in forms
default=...Value used when none is provided
unique=TrueEnforces uniqueness across the table
choices=...Restricts values to a fixed set
db_index=TrueAdds a database index for faster lookups

💡 null vs. blank — the classic confusion

null is about the database (can the column store NULL?). blank is about validation (can a form leave it empty?). For text fields, prefer blank=True alone and store an empty string rather than NULL — that way you don't have two "empty" values to reason about.

Using choices

Modern Django gives you an ergonomic way to declare choices with an enumeration class:

class Book(models.Model):
    class Genre(models.TextChoices):
        SCIFI = "SCI", "Science Fiction"
        MYSTERY = "MYS", "Mystery"
        ROMANCE = "ROM", "Romance"
        NONFICTION = "NON", "Non-Fiction"

    title = models.CharField(max_length=200)
    genre = models.CharField(
        max_length=3,
        choices=Genre.choices,
        default=Genre.MYSTERY,
    )

Django then generates a display helper automatically:

book = Book.objects.get(id=1)
print(book.genre)                  # "MYS"
print(book.get_genre_display())    # "Mystery"

Model Relationships

Real data is connected. Django models three kinds of relationship, each with a dedicated field.

1. One-to-many — ForeignKey

Many rows point to one. An author writes many books; each book has one author.

class Author(models.Model):
    name = models.CharField(max_length=100)

    def __str__(self):
        return self.name

class Book(models.Model):
    title = models.CharField(max_length=200)
    author = models.ForeignKey(
        Author,
        on_delete=models.CASCADE,
        related_name="books",
    )

    def __str__(self):
        return self.title

The related_name lets you walk the relationship backwards:

rowling = Author.objects.get(name="J.K. Rowling")
rowling.books.all()   # every Book with author=rowling

⚠️ on_delete is required — choose deliberately

  • CASCADE — delete the books when the author is deleted.
  • PROTECT — refuse to delete an author who still has books.
  • SET_NULL — orphan the books (requires null=True).

Picking CASCADE by reflex can silently wipe out data. Think about what should happen.

2. Many-to-many — ManyToManyField

Rows on both sides can link to many of the other. A book has several categories; a category groups many books.

class Category(models.Model):
    name = models.CharField(max_length=50)

    def __str__(self):
        return self.name

class Book(models.Model):
    title = models.CharField(max_length=200)
    categories = models.ManyToManyField(Category, related_name="books")

When the link itself needs data (say, when a book was added to a category), add a through model:

class BookCategory(models.Model):
    book = models.ForeignKey(Book, on_delete=models.CASCADE)
    category = models.ForeignKey(Category, on_delete=models.CASCADE)
    added_on = models.DateTimeField(auto_now_add=True)
    featured = models.BooleanField(default=False)

class Book(models.Model):
    title = models.CharField(max_length=200)
    categories = models.ManyToManyField(
        Category, through="BookCategory", related_name="books"
    )

3. One-to-one — OneToOneField

Exactly one row on each side. The classic case is extending the built-in User with a profile.

from django.conf import settings

class Profile(models.Model):
    user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
    bio = models.TextField(blank=True)
    birth_date = models.DateField(null=True, blank=True)

    def __str__(self):
        return f"{self.user.username}'s profile"

Here is how those three relationships look as an entity-relationship diagram:

erDiagram AUTHOR ||--o{ BOOK : writes BOOK }o--o{ CATEGORY : belongs_to USER ||--|| PROFILE : has AUTHOR { int id string name } BOOK { int id string title int author_id } CATEGORY { int id string name } PROFILE { int id int user_id text bio }

Migrations

You've defined models in Python — but the database doesn't know about them yet. Migrations bridge that gap. They are version control for your schema: small, ordered files that describe each change and can be applied or rolled back.

graph LR A[Edit models.py] --> B[makemigrations] B -->|writes migration file| C[0001_initial.py] C --> D[migrate] D -->|applies SQL| E[(Database)]

The workflow is always the same two commands:

# 1. Detect model changes and write a migration file
python manage.py makemigrations

# 2. Apply pending migrations to the database
python manage.py migrate

Other commands you'll use often:

# See which migrations exist and which are applied
python manage.py showmigrations

# Preview the raw SQL a migration will run
python manage.py sqlmigrate blog 0001

# Give a migration a readable name
python manage.py makemigrations --name add_slug_to_post blog

# Roll an app back to an earlier migration
python manage.py migrate blog 0001

✅ Migration habits that save you pain

  • Always commit migration files — they're part of your source, not build artifacts.
  • Make small, incremental changes rather than one giant migration.
  • Review the generated file before applying it to production.
  • Test migrations on a copy of production data before the real run.

Methods, Meta & Managers

Models are more than columns. They're a natural home for the logic that belongs with your data — the "fat models, thin views" philosophy.

Model methods

Add methods to encapsulate behavior on a single instance:

from django.utils import timezone
from django.urls import reverse

class Post(models.Model):
    title = models.CharField(max_length=200)
    content = models.TextField()
    published_at = models.DateTimeField(null=True, blank=True)
    is_published = models.BooleanField(default=False)

    def __str__(self):
        return self.title

    def publish(self):
        """Mark this post live, right now."""
        self.published_at = timezone.now()
        self.is_published = True
        self.save()

    def is_recent(self):
        return bool(self.published_at) and \
            (timezone.now() - self.published_at).days < 7

    def get_absolute_url(self):
        return reverse("post_detail", kwargs={"pk": self.pk})

Overriding save()

A common pattern is auto-generating a slug from the title:

from django.utils.text import slugify

class Article(models.Model):
    title = models.CharField(max_length=200)
    slug = models.SlugField(unique=True, max_length=200, blank=True)

    def save(self, *args, **kwargs):
        if not self.slug:
            self.slug = slugify(self.title)
        super().save(*args, **kwargs)

The Meta class

An inner Meta class configures model-wide behavior — default ordering, indexes, constraints, and human-readable names:

class Book(models.Model):
    title = models.CharField(max_length=200)
    author = models.ForeignKey(Author, on_delete=models.CASCADE)
    publication_date = models.DateField()

    class Meta:
        ordering = ["-publication_date", "title"]
        indexes = [models.Index(fields=["publication_date"])]
        constraints = [
            models.UniqueConstraint(
                fields=["title", "author"], name="unique_title_per_author"
            )
        ]
        verbose_name_plural = "books"

Custom managers

Every model has a default manager called objects. A custom manager lets you name a common query once and reuse it everywhere:

class PublishedManager(models.Manager):
    def get_queryset(self):
        return super().get_queryset().filter(status="published")

    def recent(self, limit=5):
        return self.get_queryset().order_by("-published_at")[:limit]

class Article(models.Model):
    STATUS = [("draft", "Draft"), ("published", "Published")]

    title = models.CharField(max_length=200)
    status = models.CharField(max_length=10, choices=STATUS, default="draft")
    published_at = models.DateTimeField(null=True, blank=True)

    objects = models.Manager()       # the default manager
    published = PublishedManager()   # our custom one

    def __str__(self):
        return self.title

Now the intent reads clearly at the call site:

Article.published.all()       # only published articles
Article.published.recent(3)   # three most recent published

⚠️ Declare the default manager explicitly

The moment you add any custom manager, Django stops adding objects for you. If you still want Model.objects, declare objects = models.Manager() yourself — as shown above.

Querying with the ORM

The ORM turns database work into Python. Queries are lazy — nothing hits the database until you actually iterate, index, or evaluate the result — which lets you chain filters efficiently.

The essentials

# Everything
Book.objects.all()

# One row by primary key (raises if missing)
Book.objects.get(pk=1)

# Filter and exclude
Book.objects.filter(genre="SCI")
Book.objects.exclude(genre="SCI")

# Order and slice (slicing does SQL LIMIT/OFFSET)
Book.objects.order_by("-publication_date")[:5]

Field lookups

Double-underscore lookups express SQL conditions:

Book.objects.filter(title__icontains="hobbit")        # case-insensitive contains
Book.objects.filter(publication_date__gte="2020-01-01")  # >=
Book.objects.filter(genre__in=["SCI", "MYS"])          # IN (...)
Book.objects.filter(title__startswith="The")           # LIKE 'The%'
Book.objects.filter(summary__isnull=True)              # IS NULL

Complex conditions with Q objects

For OR, NOT, and grouped logic, use Q:

from django.db.models import Q

# genre is SCI OR MYS
Book.objects.filter(Q(genre="SCI") | Q(genre="MYS"))

# published in 2020 AND NOT a Harry Potter book
Book.objects.filter(
    Q(publication_date__year=2020) & ~Q(title__contains="Harry Potter")
)

Aggregation & annotation

from django.db.models import Count, Avg

# One summary value across the whole table
Book.objects.aggregate(avg_price=Avg("price"))

# A computed field attached to each row
Author.objects.annotate(book_count=Count("books")).filter(book_count__gt=5)

💡 Beware the N+1 query

Looping over books and touching book.author.name fires one extra query per book. Use select_related("author") (for foreign keys) or prefetch_related("categories") (for many-to-many) to fetch related rows in a single query. This is the most impactful performance fix in most Django apps.

Hands-on: Model a Blog

🏋️ Design, migrate, and query a small blog domain

Objective: Turn a set of requirements into models, apply migrations, and query them in the shell.

Requirements:

  1. A Category has a unique name and an auto-generated slug.
  2. A Post belongs to one category and one author, has a status of draft/published, and records when it was published.
  3. A published-only custom manager exposes Post.published.
💡 Hint — where should the slug logic live?

Override save() on the model and call slugify() only when the slug is still empty, so edits don't clobber an existing slug. For the manager, subclass models.Manager and filter in get_queryset().

✅ Sample solution
# blog/models.py
from django.conf import settings
from django.db import models
from django.utils import timezone
from django.utils.text import slugify

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

    class Meta:
        ordering = ["name"]
        verbose_name_plural = "categories"

    def __str__(self):
        return self.name

    def save(self, *args, **kwargs):
        if not self.slug:
            self.slug = slugify(self.name)
        super().save(*args, **kwargs)

class PublishedManager(models.Manager):
    def get_queryset(self):
        return super().get_queryset().filter(
            status=Post.Status.PUBLISHED,
            published_at__lte=timezone.now(),
        )

class Post(models.Model):
    class Status(models.TextChoices):
        DRAFT = "draft", "Draft"
        PUBLISHED = "published", "Published"

    title = models.CharField(max_length=200)
    slug = models.SlugField(max_length=200, unique=True, blank=True)
    author = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="posts",
    )
    category = models.ForeignKey(
        Category, on_delete=models.SET_NULL,
        null=True, related_name="posts",
    )
    content = models.TextField()
    status = models.CharField(
        max_length=10, choices=Status.choices, default=Status.DRAFT
    )
    published_at = models.DateTimeField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)

    objects = models.Manager()
    published = PublishedManager()

    class Meta:
        ordering = ["-published_at", "-created_at"]
        indexes = [models.Index(fields=["-published_at"])]

    def __str__(self):
        return self.title

    def save(self, *args, **kwargs):
        if not self.slug:
            self.slug = slugify(self.title)
        if self.status == self.Status.PUBLISHED and not self.published_at:
            self.published_at = timezone.now()
        super().save(*args, **kwargs)
python manage.py makemigrations blog
python manage.py migrate
python manage.py shell
# In the shell — verify the custom manager works
>>> from blog.models import Post
>>> Post.published.all()        # only published, past-dated posts
>>> Post.objects.count()        # every post, drafts included

Best Practices

✅ Do

  • Give every model a meaningful __str__.
  • Use DecimalField for money and pick field types deliberately.
  • Set on_delete with intent, not reflex.
  • Push instance logic into model methods ("fat models, thin views").
  • Add indexes to fields you filter or sort on often.
  • Use select_related / prefetch_related to kill N+1 queries.

⚠️ Don't

  • Use null=True on text fields — prefer an empty string.
  • Default every foreign key to CASCADE without thinking.
  • Forget to declare objects = models.Manager() once you add a custom manager.
  • Delete or hand-edit committed migration files that others have already applied.

Summary & Quiz

🎉 Key Takeaways

  • A model is a Python class that maps to a database table; each attribute is a column.
  • Pick field types deliberately, and know the null (database) vs. blank (forms) distinction.
  • Relationships come in three kinds: ForeignKey, ManyToManyField, and OneToOneField.
  • Migrations are version control for your schema — makemigrations then migrate.
  • Add behavior with methods, Meta, and managers, and query with the lazy, chainable QuerySet API.

🎯 Quick Quiz

Question 1: Which field type should you use to store a product's price?

Question 2: What is the difference between null=True and blank=True?

Question 3: Which two commands take a model change all the way to the database?

📚 Further Reading

🚀 What's Next?

Your data has a shape and a schema. Next, in Views and URL Patterns, you'll expose that data to the world — routing requests to views and turning querysets into pages.

🎉 Solid work!

You can model a domain and query it fluently. Time to serve it up.