The Database with Django's ORM

Website Rebuild with Django

Chapter 6 · The Database with Django's ORM

The Page model has existed as Python since Chapter 2 — nothing about it is real in an actual database yet. This chapter makes it real, the same job Website Rebuild with Next.js 6 gave to Prisma, and a second real-world instance of relational modeling this site already worked through once, in Food Tracker (Django) 2. Along the way, this chapter also covers the single most common way a Django ORM query quietly costs far more than it looks like it should — and points directly at a real, live example already sitting in this course's own code.

Migrations: makemigrations & migrate

python manage.py makemigrations content python manage.py migrate

makemigrations compares models.py against what Django last knew about and writes a migration file describing the difference. migrate actually applies it to the database. Prisma's own workflow (prisma migrate dev) does conceptually the same two-step job, but the artifact it produces is different: Prisma generates a raw .sql migration file directly, where Django generates real, executable Python describing the change.

What a Migration File Actually Contains

# content/migrations/0001_initial.py from django.db import migrations, models import django.db.models.deletion class Migration(migrations.Migration): initial = True operations = [ migrations.CreateModel( name='Page', fields=[ ('id', models.AutoField(primary_key=True, serialize=False)), ('title', models.CharField(max_length=200)), ('slug', models.SlugField(max_length=100)), ('full_path', models.CharField(db_index=True, max_length=500, unique=True)), ('body', models.TextField(blank=True)), ('parent', models.ForeignKey( blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='children', to='content.page', )), ], ), ]

Even the self-referential parent field appears exactly as declared back in Chapter 2 — Django resolved 'self' down to a real, explicit reference (to='content.page') once it generated this file.

The Django Shell: Testing the ORM Interactively

python manage.py shell >>> from content.models import Page >>> root = Page.objects.create(title="Programming", slug="programming") >>> child = Page.objects.create(title="Python", slug="python", parent=root) >>> child.full_path 'programming/python'

Prisma Studio does the same job through a GUI; the Django shell is a plain Python REPL with the ORM already imported and ready — no separate tool to install or launch.

QuerySets Are Lazy

qs = Page.objects.filter(title__icontains="python") # NO query has run yet qs = qs.exclude(slug="old-python") # still no query list(qs) # NOW — exactly one combined query actually runs, right here

A QuerySet only describes a query — it doesn't execute anything until it's actually evaluated (iterated, converted with list(), sliced for display, and so on). Chaining .filter()/.exclude() across several lines never means several queries; it builds up one query description that runs exactly once, at the moment something finally needs real rows.

The N+1 Problem — Including One Already Sitting in This Codebase

# Chapter 4's own breadcrumb loop node = page while node: breadcrumb.insert(0, node) node = node.parent # each access after the first is a SEPARATE query
Honest, not overstated: a real N+1 pattern already exists in this course
Every node.parent access that hasn't already been loaded triggers its own fresh query — a five-level-deep page means up to five separate round trips just to build one breadcrumb. That's a genuine N+1 pattern, and it's worth naming rather than pretending the code written so far is already perfectly optimized. It's also, honestly, a small and currently acceptable cost: breadcrumb depth is bounded (this site never goes deeper than a handful of levels), so five small queries per page view isn't the kind of problem that demands an immediate fix. The identical pattern applied to something unbounded — a page listing showing many rows, each needing its own parent's title — is where this stops being a minor inefficiency and becomes a real one.

select_related: Fixing It With a Real JOIN

# Without select_related — one extra query PER ROW, in the loop (N+1) pages = Page.objects.filter(parent__isnull=False) for p in pages: print(p.parent.title) # a fresh query, every single iteration # With select_related — ONE query total, parent JOINed in up front pages = Page.objects.filter(parent__isnull=False).select_related('parent') for p in pages: print(p.parent.title) # no extra query — already fetched via the JOIN
The central fact this chapter is built on
The ORM hides the SQL — it never hides the cost. Recognizing when a chain of attribute access like .parent.parent is secretly N separate queries, rather than one, is a genuine, core ORM skill, not a rare edge case. This project's own Chapter 4 breadcrumb code is presented here honestly, as a live example of exactly that pattern, currently small enough not to matter — the same habit of noticing it now is what prevents it from quietly becoming a real problem once this project grows.

Hands-On Exercises

Exercise 1

Explain why qs = Page.objects.filter(...).exclude(...), written across two separate lines, doesn't run two separate database queries. What actually triggers the query to run?

📄 View solution
Exercise 2

Explain why Chapter 4's breadcrumb-building loop is a real N+1 query pattern, and why this chapter treats it as an acceptable, bounded cost right now rather than something demanding an immediate fix.

📄 View solution
Exercise 3

Given a page listing that loops over many pages and accesses page.parent.title for each one, write the corrected queryset using select_related, and explain specifically what problem it solves.

📄 View solution

Chapter 6 Quick Reference

  • makemigrations / migrate — generate a real Python migration file describing model changes, then apply it
  • Django migrations are Python, not raw SQL — a genuine mechanical difference from Prisma's own .sql migration files
  • python manage.py shell — an interactive Python REPL with the ORM ready to go, Django's own answer to Prisma Studio
  • QuerySets are lazy — chaining .filter()/.exclude() builds one query description; nothing runs until it's actually evaluated
  • The N+1 problem — an unguarded relationship access inside a loop triggers one query per row, not one query total
  • A real, current example — Chapter 4's own breadcrumb loop, honestly named as N+1, currently small enough to be acceptable
  • select_related('field') — fetches a related row via a real SQL JOIN up front, turning N+1 queries into one
  • Next chapter: Rendering Content & the Kanji Edge Case