🗄️ 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
Qobjects
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:
- Create a table (by default named
<app>_book). - Add a column for each field.
- Add an auto-incrementing
idprimary key automatically. - 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.
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.
| Field | Stores | Example |
|---|---|---|
CharField | Short text (needs max_length) | models.CharField(max_length=100) |
TextField | Long, unbounded text | models.TextField() |
IntegerField | Whole numbers | models.IntegerField() |
DecimalField | Exact decimals (money!) | models.DecimalField(max_digits=6, decimal_places=2) |
BooleanField | True / False | models.BooleanField(default=True) |
DateTimeField | Date + time | models.DateTimeField(auto_now_add=True) |
EmailField | Validated email | models.EmailField() |
SlugField | URL-safe labels | models.SlugField(unique=True) |
ImageField | Uploaded images | models.ImageField(upload_to="covers/") |
JSONField | Structured JSON | models.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:
| Option | Effect |
|---|---|
null=True | Allows NULL in the database |
blank=True | Allows the field to be empty in forms |
default=... | Value used when none is provided |
unique=True | Enforces uniqueness across the table |
choices=... | Restricts values to a fixed set |
db_index=True | Adds 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 (requiresnull=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:
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.
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:
- A
Categoryhas a unique name and an auto-generated slug. - A
Postbelongs to one category and one author, has a status of draft/published, and records when it was published. - 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
DecimalFieldfor money and pick field types deliberately. - Set
on_deletewith 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_relatedto kill N+1 queries.
⚠️ Don't
- Use
null=Trueon text fields — prefer an empty string. - Default every foreign key to
CASCADEwithout 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 —
makemigrationsthenmigrate. - 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.