PythonMastery
intermediate 26 min read · lesson 10 of 12 in Web Frameworks

Django Models & ORM

1 · The lesson

read

The Django ORM is the part of the framework people love or fight, often both in the same week. It's mature, terse, and well-integrated — Article.objects.filter(status="published").select_related("author")[:10] reads almost like English. It also hides a lot of SQL, and the moment you stop thinking about what queries it actually generates, you end up shipping an N+1 to production.

This lesson covers fields, the migration cycle, QuerySets, F and Q expressions, aggregations, and the eager-loading patterns (select_related / prefetch_related) that separate Django code that scales from Django code that grinds to a halt.

Run locally with pip install django and python manage.py runserver. Use python manage.py shell for the interactive ORM examples below.


1. A Model Is a Python Class That Maps to a Table

python
# blog/models.py
from django.db import models
from django.contrib.auth.models import User

class Article(models.Model):
    title = models.CharField(max_length=200)
    slug = models.SlugField(unique=True)
    body = models.TextField()
    status = models.CharField(
        max_length=10,
        choices=[("draft", "Draft"), ("published", "Published")],
        default="draft",
    )
    views = models.IntegerField(default=0)
    author = models.ForeignKey(User, on_delete=models.CASCADE, related_name="articles")
    published_at = models.DateTimeField(null=True, blank=True)
    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

What's happening here:

  • The class inherits from models.Model and each attribute is a field — Django uses these to generate a CREATE TABLE.
  • An id column is added automatically as the primary key. You almost never need to declare it.
  • ForeignKey(User, on_delete=models.CASCADE) is a real database FK. on_delete is mandatory — pick CASCADE (delete the article when the author is deleted), PROTECT (refuse to delete the author if articles exist), SET_NULL (orphan the articles), or SET_DEFAULT.
  • related_name="articles" gives you the reverse accessor — user.articles.all() to fetch all of a user's articles. Without it, you'd write user.article_set.all().
  • Meta is the per-model config — ordering, indexes, db_table, verbose_name, unique_together, constraints. The Meta.ordering you set here applies to every query unless overridden.
  • __str__ is what shows in the admin and the shell. Define it on every model — <Article: Article object (42)> is useless.

2. The Field Tour

The fields you'll use 95% of the time:

FieldSQL typeWhen
CharField(max_length=N)VARCHAR(N)Short strings — titles, names, slugs
TextField()TEXTUnbounded prose — bodies, descriptions
IntegerField()INTEGERCounters, IDs, ages
BigIntegerField()BIGINTWhen INTEGER might overflow (~2B)
FloatField() / DecimalField(max_digits, decimal_places)FLOAT / NUMERICFloats for science; Decimal for money
BooleanField()BOOLEANFlags. Use default=False, not null=True
DateField() / DateTimeField()DATE / TIMESTAMPauto_now_add=True sets on create; auto_now=True updates on every save
EmailField() / URLField() / SlugField()VARCHAR with validatorsValidated CharField variants
UUIDField(default=uuid.uuid4)UUIDWhen you want non-sequential primary keys
JSONField()JSONB (Postgres) / TEXTSemi-structured data — config, properties
ForeignKey(M, on_delete=...)INTEGER REFERENCES ...Many-to-one relationship
OneToOneField(M, on_delete=...)FK + UNIQUEProfile-extends-User patterns
ManyToManyField(M)Through tableTags on articles, members in groups

Two flags every field accepts:

  • null=True — the column accepts NULL in the database.
  • blank=True — Django forms and the admin allow the field to be empty.

The two are independent. For CharField, Django convention is to leave null=False (empty string is the absence value) and toggle blank for form acceptance. For everything else, null=True and blank=True usually move together.


3. The Migration Cycle

Django models don't touch the database directly — you describe the schema in Python, then migrations translate changes into SQL.

bash
# After editing models.py:
python manage.py makemigrations blog        # generates blog/migrations/0002_*.py
python manage.py migrate                    # applies pending migrations

makemigrations diffs your current models.py against the last applied migration and writes a Python file describing the change. Commit migrations to git — they're part of the source of truth.

A few things to internalise:

  • Always review the generated migration before applying it. Look at blog/migrations/0002_*.py — does the change look right? Adding a non-null column with no default will halt with "you are trying to add a non-nullable field" — Django is making sure you've thought about what existing rows should contain.
  • Never edit a migration after it's been applied to any environment that matters. Create a new migration that adjusts. The migration history is append-only by design.
  • Squashing — once you have 47 migrations on a single app, python manage.py squashmigrations blog 0001 0040 collapses them into one. Useful for keeping the migrations folder tidy after a year of churn.
  • Data migrations — for moving data, not schema. Use RunPython(forwards_func, backwards_func) inside a migration file. Keep them idempotent.
bash
python manage.py showmigrations             # what's applied, what's pending
python manage.py sqlmigrate blog 0002       # show the SQL a migration would run

sqlmigrate is invaluable for review — see exactly what Django plans to send to the database.


4. QuerySets — Lazy, Chainable, Cached

Article.objects is a Manager. Calling things on it returns a QuerySet:

python
Article.objects.all()                                  # SELECT * FROM articles
Article.objects.filter(status="published")             # WHERE status = 'published'
Article.objects.exclude(status="draft")                # WHERE NOT (status = 'draft')
Article.objects.filter(views__gt=100)                  # WHERE views > 100
Article.objects.filter(title__icontains="django")      # WHERE title ILIKE '%django%'
Article.objects.filter(author__email="a@b.com")        # JOIN to user, filter on email
Article.objects.get(id=1)                              # exactly one row — or DoesNotExist / MultipleObjectsReturned
Article.objects.first()                                # first row or None
Article.objects.order_by("-created_at")[:10]           # ORDER BY ... LIMIT 10
+ setup added so this can run · defines Article
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

Article = _AutoMock('Article')

The double-underscore __ is the lookup operator: field__gt, field__lt, field__icontains, field__in, field__isnull, field__date, etc. Chain them across relationships: filter(author__profile__country="UK").

Three properties of QuerySets that trip people up:

1. They're lazy. Building one doesn't hit the database. Evaluation happens on iteration, list(), bool(), len(), slicing with a stop, or repr() (the shell trap):

python
qs = Article.objects.filter(status="published")        # no SQL yet
qs = qs.order_by("-created_at")                        # still no SQL
for article in qs:                                     # NOW the query runs
    print(article.title)
+ setup added so this can run · defines Article
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

Article = _AutoMock('Article')

2. They cache. Iterating a QuerySet a second time uses the cached results — no second query. But slicing a not-yet-cached queryset creates a new one:

python
qs = Article.objects.all()
list(qs)                                               # query runs, results cached
list(qs)                                               # cache hit, no query
qs[:5]                                                 # new queryset, would query again
+ setup added so this can run · defines Article
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

Article = _AutoMock('Article')

3. Chaining returns a new QuerySet. Each call (filter, exclude, order_by) returns a fresh QuerySet — no mutation. Build a base query and branch from it:

python
published = Article.objects.filter(status="published")
recent = published.order_by("-created_at")[:10]
featured = published.filter(featured=True)
+ setup added so this can run · defines Article
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

Article = _AutoMock('Article')

Three queries, one base, no surprises.


5. F Expressions — Atomic Updates

Read-modify-write in Python is a race condition. Two requests both see views=100, both compute 100 + 1, both save 101. You've lost a view.

python
# WRONG — race condition under concurrency
article = Article.objects.get(id=1)
article.views += 1
article.save()
+ setup added so this can run · defines Article
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

Article = _AutoMock('Article')

F expressions push the arithmetic into SQL — atomic at the database level:

python
from django.db.models import F

Article.objects.filter(id=1).update(views=F("views") + 1)
+ setup added so this can run · defines Article
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

Article = _AutoMock('Article')

The generated SQL is UPDATE articles SET views = views + 1 WHERE id = 1 — one statement, atomic, no race. Use F expressions any time the new value depends on the current value.

F also lets you compare two columns:

python
Article.objects.filter(updated_at__gt=F("created_at"))     # edited articles
Author.objects.filter(books_written__lt=F("books_promised"))
+ setup added so this can run · defines Article, F, Author
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

Article = _AutoMock('Article')
def F(*_a, **_kw):
    print('-> F() called')
    return _AutoMock('F()')
Author = _AutoMock('Author')

6. Q Objects — OR and Complex Boolean Logic

filter(a=1, b=2) is AND. For OR, you need Q objects:

python
from django.db.models import Q

# WHERE status = 'published' OR featured = True
Article.objects.filter(Q(status="published") | Q(featured=True))

# WHERE (status='published' OR featured=True) AND views > 100
Article.objects.filter(Q(status="published") | Q(featured=True), views__gt=100)

# NOT
Article.objects.filter(~Q(status="draft"))
+ setup added so this can run · defines Article
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

Article = _AutoMock('Article')

Operators: | for OR, & for AND, ~ for NOT. Wrap any non-trivial OR in Q — chaining .filter() is AND and there's no .or_filter.


7. Aggregations and Annotations

The two halves of statistical SQL:

  • aggregate — collapse a QuerySet into a single dict of numbers.
  • annotate — add a computed column to each row in the QuerySet.
python
from django.db.models import Count, Avg, Max, Sum

# aggregate: one row of stats
Article.objects.aggregate(
    total=Count("id"),
    avg_views=Avg("views"),
    most_viewed=Max("views"),
)
# {'total': 142, 'avg_views': 387.5, 'most_viewed': 12450}

# annotate: one extra column per row
authors = User.objects.annotate(
    article_count=Count("articles"),
    total_views=Sum("articles__views"),
).order_by("-article_count")

for author in authors:
    print(author.username, author.article_count, author.total_views)
+ setup added so this can run · defines Article, User
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

Article = _AutoMock('Article')
User = _AutoMock('User')

annotate is the workhorse for leaderboards, dashboards, and any "show me each X with its count of Y". The generated SQL uses GROUP BY and joins — way faster than a Python loop with N queries.


8. The N+1 Problem and How to Fix It

The defining ORM footgun. Looks innocent, costs you the database under load.

python
# Template: 1 query for articles, then 1 per article for its author. N+1.
articles = Article.objects.filter(status="published")
for article in articles:
    print(article.title, article.author.username)      # lazy-loads author EACH TIME
+ setup added so this can run · defines Article
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

Article = _AutoMock('Article')

50 articles fires 51 queries. Production traffic turns this into an outage.

Django offers two fixes, depending on the relationship:

select_related — for ForeignKey and OneToOneField (the "many-to-one" side). Issues a SQL JOIN, returns one row per article with author columns included.

python
articles = Article.objects.select_related("author").filter(status="published")
for article in articles:
    print(article.title, article.author.username)      # no extra query
+ setup added so this can run · defines Article
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

Article = _AutoMock('Article')

prefetch_related — for ManyToManyField and reverse ForeignKey (the "one-to-many" side). Issues a second query with WHERE id IN (...) and joins the results in Python.

python
articles = Article.objects.prefetch_related("tags").all()
for article in articles:
    print(article.title, [t.name for t in article.tags.all()])    # tags pre-loaded
+ setup added so this can run · defines Article
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

Article = _AutoMock('Article')

You can chain across relationships with double-underscore: select_related("author__profile"), prefetch_related("comments__author").

The detection: turn on DEBUG=True and django-debug-toolbar, or set LOGGING to log every SQL query, or use connection.queries in the shell. Any time you see one query per row in a loop, that's an N+1.


9. Transactions

Django wraps each HTTP request in a transaction by default (with ATOMIC_REQUESTS=True in DATABASES). For finer control:

python
from django.db import transaction

@transaction.atomic
def transfer(from_id, to_id, amount):
    Account.objects.filter(id=from_id).update(balance=F("balance") - amount)
    Account.objects.filter(id=to_id).update(balance=F("balance") + amount)
+ setup added so this can run · defines F, Account
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

def F(*_a, **_kw):
    print('-> F() called')
    return _AutoMock('F()')
Account = _AutoMock('Account')

If the function raises, the whole transaction rolls back. As a context manager:

python
with transaction.atomic():
    Article.objects.filter(status="draft").update(status="archived")
    Author.objects.filter(active=False).delete()
+ setup added so this can run · defines transaction, Article, Author
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

transaction = _AutoMock('transaction')
Article = _AutoMock('Article')
Author = _AutoMock('Author')

For long-running tasks (a 30-minute report), don't hold a transaction open — you'll lock rows that other requests need.


10. Custom Managers

The default manager is objects. You can replace or extend it:

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

class Article(models.Model):
    # ... fields ...
    objects = models.Manager()           # the default — all rows
    published = PublishedManager()       # only published rows
+ setup added so this can run · defines models
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

models = _AutoMock('models')

Now Article.published.all() returns only published articles — and you can layer further with Article.published.filter(featured=True). It's the cleanest way to keep "default scope" logic out of every view.

A close cousin is the QuerySet subclass plus Manager.from_queryset(...) — same idea, but methods you add are chainable. The Django docs are good here; reach for it when you find yourself writing filter(status="published") in fifteen places.


11. The Admin, More Carefully

We saw admin.site.register(Article) in the previous lesson. Real-world admin classes customise heavily:

python
@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
    list_display = ("title", "author", "status", "views", "created_at")
    list_filter = ("status", "author")
    search_fields = ("title", "body")
    date_hierarchy = "created_at"
    raw_id_fields = ("author",)              # FK as ID + lookup widget, not a 10K-option dropdown
    list_select_related = ("author",)         # avoids N+1 in the list view itself
    readonly_fields = ("created_at", "updated_at")
    prepopulated_fields = {"slug": ("title",)}
+ setup added so this can run · defines admin, Article
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

admin = _AutoMock('admin')
Article = _AutoMock('Article')

The list_select_related line is the admin's own N+1 fix — without it, rendering the list page fires one query per row for the author. With it, one JOIN.


12. Signals — Useful, Easy to Abuse

Django emits signals at key model events: pre_save, post_save, pre_delete, post_delete, m2m_changed.

python
from django.db.models.signals import post_save
from django.dispatch import receiver

@receiver(post_save, sender=Article)
def create_audit_log(sender, instance, created, **kwargs):
    if created:
        AuditLog.objects.create(action="article-created", target=instance.id)
+ setup added so this can run · defines Article, AuditLog
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

Article = _AutoMock('Article')
AuditLog = _AutoMock('AuditLog')

Useful for cross-cutting concerns (audit logs, cache invalidation, search indexing). The trap: signals make code spooky — the call site has no idea that saving an Article also creates a log entry, sends an email, and re-indexes Elasticsearch. Debugging "why did this side-effect happen?" gets painful.

Rule of thumb: if the action is logically part of saving the article, override save(). Use signals only when the receiver lives in a different app and can't be coupled to the sender.


13. Common Mistakes

1. N+1 in templates. {% for article in articles %}{{ article.author.username }}{% endfor %} looks innocent and fires one query per article. Add .select_related("author") to the view's queryset.

2. Calling list methods on QuerySets. A QuerySet is not a list. qs.append(x) doesn't exist; you create instances with Model.objects.create(...). Confusion with qs[0] (which works) and qs.add(x) (only on m2m managers).

3. Editing migrations after applying them. Once 0042_add_field.py has been applied to staging, modifying it diverges your local state from staging's django_migrations table. Create 0043_fix_field.py instead.

4. Forgetting on_delete on ForeignKey. Django requires you to pick. The right answer depends on semantics — CASCADE for "child rows don't exist without the parent", PROTECT to refuse deletion, SET_NULL for "orphan but keep". There is no good default; think about it.

5. Using .get() when you want .first(). Model.objects.get(...) raises DoesNotExist for zero rows and MultipleObjectsReturned for >1. Wrap it in try/except or use .filter(...).first() if "not found" is normal.

6. values() vs values_list() vs model instances. Article.objects.values("title", "views") returns dicts. .values_list("title", "views") returns tuples (add flat=True for a flat list of one column). Use these when you need raw data and don't want model overhead — much faster than instantiating every row.

7. Putting derived fields on the model when they could be properties. A total_price that's always quantity * unit_price doesn't need a column — it needs a @property. Storing derived data means keeping it in sync forever.


🎯 Your Turn — Tags With No N+1

Extend the tasks app from the previous lesson. Add a Tag model and a many-to-many relationship from Task. Then write a view that lists tasks with their tags — using prefetch_related to avoid N+1.

Requirements:

1. Define a Tag model with a unique name (max 50 chars).
2. Add tags = ManyToManyField(Tag, blank=True, related_name="tasks") to Task.
3. Run makemigrations tasks and migrate.
4. Write a task_list_with_tags view that returns all tasks, eager-loading tags.
5. Render each task with its tag names.
6. Verify in the shell that the query count is constant (not proportional to the number of tasks).

Skeleton:

python
# tasks/models.py
from django.db import models

class Tag(models.Model):
    name = models.CharField(max_length=50, unique=True)

    def __str__(self):
        return self.name

class Task(models.Model):
    title = models.CharField(max_length=200)
    done = models.BooleanField(default=False)
    created_at = models.DateTimeField(auto_now_add=True)
    # TODO 1: add a tags M2M field

    class Meta:
        ordering = ["-created_at"]

    def __str__(self):
        return self.title
python
# tasks/views.py
from django.shortcuts import render
from .models import Task

def task_list_with_tags(request):
    # TODO 2: build a queryset that eager-loads tags
    # TODO 3: render to "tasks/task_list_tagged.html"
    ...
html
{# tasks/templates/tasks/task_list_tagged.html #}
<h1>Tasks</h1>
<ul>
  {# TODO 4: for each task, show its title plus a comma-separated list of tag names #}
</ul>

To verify the query count:

bash
python manage.py shell
>>> from django.db import connection, reset_queries
>>> from django.conf import settings
>>> settings.DEBUG = True
>>> reset_queries()
>>> from tasks.models import Task
>>> # TODO 5: build the queryset, force evaluation, then count connection.queries
Hint 1 — prefetch_related vs select_related Tags are ManyToManyField, so it's prefetch_related("tags"), not select_related. select_related is for ForeignKey / OneToOneField — the "many-to-one" side where a JOIN works.
Hint 2 — Looping over the M2M in the template {% for tag in task.tags.all %}{{ tag.name }}{% if not forloop.last %}, {% endif %}{% endfor %} — without the prefetch, this fires a query per task. With the prefetch, the data is already in memory and Django serves it from there.
Show full solution
python
# tasks/models.py
from django.db import models

class Tag(models.Model):
    name = models.CharField(max_length=50, unique=True)

    def __str__(self):
        return self.name

class Task(models.Model):
    title = models.CharField(max_length=200)
    done = models.BooleanField(default=False)
    created_at = models.DateTimeField(auto_now_add=True)
    tags = models.ManyToManyField(Tag, blank=True, related_name="tasks")

    class Meta:
        ordering = ["-created_at"]

    def __str__(self):
        return self.title
python
# tasks/views.py
from django.shortcuts import render
from .models import Task

def task_list_with_tags(request):
    tasks = Task.objects.prefetch_related("tags").all()
    return render(request, "tasks/task_list_tagged.html", {"tasks": tasks})
python
# tasks/urls.py
from django.urls import path
from . import views

urlpatterns = [
    path("", views.task_list, name="task-list"),
    path("tagged/", views.task_list_with_tags, name="task-list-tagged"),
]
html
{# tasks/templates/tasks/task_list_tagged.html #}
<!doctype html>
<html>
<head><meta charset="utf-8"><title>Tasks with tags</title></head>
<body>
  <h1>Tasks</h1>
  <ul>
    {% for task in tasks %}
      <li>
        {% if task.done %}<s>{{ task.title }}</s>{% else %}{{ task.title }}{% endif %}
        {% if task.tags.all %}
          —
          {% for tag in task.tags.all %}{{ tag.name }}{% if not forloop.last %}, {% endif %}{% endfor %}
        {% endif %}
      </li>
    {% empty %}
      <li>No tasks yet.</li>
    {% endfor %}
  </ul>
</body>
</html>

Apply the migration:

bash
python manage.py makemigrations tasks
python manage.py migrate

Verify in the shell:

python
$ python manage.py shell
>>> from django.db import connection, reset_queries
>>> from django.conf import settings
>>> settings.DEBUG = True
>>> from tasks.models import Task, Tag
>>>
>>> # Seed some data
>>> t1, t2, t3 = (Task.objects.create(title=f"Task {i}") for i in range(3))
>>> work, urgent = Tag.objects.create(name="work"), Tag.objects.create(name="urgent")
>>> t1.tags.add(work, urgent); t2.tags.add(work); t3.tags.add(urgent)
>>>
>>> # Without prefetch: 1 query for tasks + 1 per task for tags = N+1
>>> reset_queries()
>>> tasks = list(Task.objects.all())
>>> _ = [list(t.tags.all()) for t in tasks]
>>> len(connection.queries)
4                                        # 1 for tasks, 3 for tag lookups
>>>
>>> # With prefetch: 1 query for tasks + 1 for all tags = 2 total, regardless of N
>>> reset_queries()
>>> tasks = list(Task.objects.prefetch_related("tags").all())
>>> _ = [list(t.tags.all()) for t in tasks]
>>> len(connection.queries)
2                                        # constant

Two queries instead of N+1. Scale that to a page of 100 tasks and you're saving 99 round-trips — the difference between a 50ms response and a 2-second one.


What You Learned

  • Models map to tables. Define fields as class attributes, set Meta.ordering, define __str__. The id PK is automatic.
  • Migrations are an append-only Python record of schema changes. makemigrations → review → migrate. Commit them. Never edit applied ones.
  • QuerySets are lazy, chainable, cached. Building one doesn't query; iterating does. Each chain call returns a new QuerySet.
  • F expressions for atomic updates that depend on current values; Q objects for OR and complex boolean filters.
  • aggregate collapses; annotate decorates each row. Count, Sum, Avg, Max are your friends for dashboards.
  • N+1 is the defining ORM trap. select_related for FK (one JOIN); prefetch_related for M2M (second query, joined in Python).
  • Transactions with @transaction.atomic or with transaction.atomic():. Don't hold them open across slow work.
  • Custom managers keep "default scope" logic out of every view.
  • Signals are useful but easy to abuse — prefer overriding save() when the logic belongs to the model.

Next: Django REST Framework — turning these models into a real JSON API with serializers, viewsets, and permissions.