Checkpoint: Models & the ORM
Reviewed & published by Brayan K
A checkpoint is a pause-and-consolidate milestone where you recall and practice everything just covered, and the Django ORM is the layer that lets you read and write database rows as ordinary Python objects instead of raw SQL.
Part of the free Django course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
In this checkpoint you'll review the whole models-and-ORM arc — migrations, fields, model methods, QuerySets with Q and F, aggregation, query performance, custom managers, and template tags — then prove it by modelling a small bookstore and answering a short quiz.
What You'll Review in This Checkpoint
- Migrations: planning with makemigrations and applying with migrate
- Model fields, plus model methods and the Meta inner class
- Building QuerySets with field lookups, Q objects, and F expressions
- Summarising data with aggregation and annotation
- Query performance: avoiding N+1 with select_related and prefetch_related
- Custom managers and reusable custom template tags and filters
📚 What You've Learned
Schema changes happen in two steps. makemigrations compares your models to the last recorded state and writes a versioned Python migration file describing the difference — it never touches the database. migrate then walks the unapplied migrations in dependency order and runs their SQL, stamping each one in the django_migrations table so it is applied exactly once.
Each field class (CharField, IntegerField, DecimalField, BooleanField, ForeignKey, and friends) maps to a database column and carries options. Remember the pair that trips everyone up: null=True controls whether the database column may store NULL, while blank=True controls whether forms allow the field to be empty. They are independent and often set together for optional text-free fields.
3. Model methods and Meta
Models are real classes, so you add behaviour as methods — a __str__ for readable labels, helper methods, and @property values computed from fields. The inner class Meta configures the model itself: default ordering, a human verbose_name, unique_together/constraints, and indexes.
4. QuerySets, Q and F
QuerySets are lazy and chainable. Field lookups like price__lt and title__icontains express WHERE clauses. Q objects let you combine conditions with OR and NOT (Q(genre="scifi") | Q(price__lt=10)), and F expressions reference a column on the database side so you can compare or update fields against each other without pulling rows into Python.
5. Aggregation and annotation
aggregate() collapses a whole QuerySet into a single dictionary of totals (one grand Avg or Count). annotate() attaches a computed value to each row, and combined with values("genre") it becomes a group-by that returns one summarised row per group.
Looping over objects and touching a relation per iteration causes the N+1 problem: one query for the list plus one per row. select_related JOINs single-valued ForeignKey/OneToOne relations into the original query, while prefetch_related runs a second query and stitches ManyToMany and reverse relations together in Python.
A custom Manager names reusable queries as methods, turning a sprawling filter into a readable Book.objects.in_stock(). It keeps business rules next to the model and out of your views, and pairing it with a custom QuerySet keeps the helpers chainable.
8. Custom template tags and filters
When the template language runs out of built-ins, you register your own. A @register.filter transforms a single value ({{ price|currency }}), and a @register.simple_tag or inclusion tag computes richer output — keeping presentation logic reusable and out of your views.
1 Review: Q-object OR Filtering
A Q object turns a condition into a value you can combine with | (OR), & (AND), and ~ (NOT). The query Book.objects.filter(Q(genre="scifi") | Q(price__lt=10)) matches a book if either branch is true. The simulation below runs that same boolean logic over a list of dicts.
# Django's Q objects let you build OR / AND filters. A query like
# Book.objects.filter(Q(genre="scifi") | Q(price__lt=10))
# matches rows where EITHER condition is true. Here we simulate that
# OR-filter over a list of dicts so you can see the boolean logic.
books = [
{"title": "Dune", "genre": "scifi", "price": 15},
{"title": "Cheap Reads", "genre": "drama", "price": 8},
{"title": "Neuromancer", "genre": "scifi", "price": 12},
{"title": "Pricey Poems", "genre": "poetry", "price": 25},
]
# Q(genre="scifi") | Q(price__lt=10)
def matches(book):
return book["genre"] == "scifi" or book["price"] < 10
results = [b["title"] for b in books if matches(book=b)]
print("OR filter matched:", results)
print("Count:", len(results))
# EXPECTED:
# OR filter matched: ['Dune', 'Cheap Reads', 'Neuromancer']
# Count: 3
# ✅ Expected output:
# OR filter matched: ['Dune', 'Cheap Reads', 'Neuromancer']
# Count: 32 Review: Group-by Aggregation
Book.objects.values("genre").annotate(total=Count("id"), avg=Avg("price")) groups the rows by genre and computes a count and average per group. The example below reproduces that group-by with a plain dictionary accumulator so the mechanics are visible.
# annotate() groups rows and computes a value per group, like
# Book.objects.values("genre").annotate(total=Count("id"), avg=Avg("price"))
# This simulates that group-by with a plain dict accumulator.
books = [
{"genre": "scifi", "price": 15},
{"genre": "scifi", "price": 12},
{"genre": "drama", "price": 8},
{"genre": "drama", "price": 20},
{"genre": "drama", "price": 11},
]
groups = {}
for b in books:
g = groups.setdefault(b["genre"], {"count": 0, "total": 0})
g["count"] += 1
g["total"] += b["price"]
for genre in sorted(groups):
g = groups[genre]
avg = g["total"] / g["count"]
print(genre, "-> count:", g["count"], "avg price:", round(avg, 2))
# EXPECTED:
# drama -> count: 3 avg price: 13.0
# scifi -> count: 2 avg price: 13.5
# ✅ Expected output:
# drama -> count: 3 avg price: 13.0
# scifi -> count: 2 avg price: 13.5🏗️ Build Challenge: Model a Bookstore
Time to put it together. Picture a small Bookstore with two models: an Author (a name) and a Book that has a price, an in_stock flag, a genre, and a ForeignKey to its author. Work through it in steps:
- Model the data as classes (the ORM stand-ins for Author and Book).
- Give Book a custom manager method that returns only in-stock books.
- Run an F-style update that raises every price by 10%.
- Run a Q-style OR filter: genre is scifi or price is under 15.
The starter below already has working defaults, so it runs and prints correct output right away. Read the TODO comments, then try to rewrite each step yourself before peeking at the solution.
# CHECKPOINT BUILD (starter). A tiny Bookstore: Authors and Books.
# Each Book has a price, an in_stock flag, and a genre, plus an author.
# Steps: (1) model the data, (2) run an F-style discount, (3) filter with
# Q-style OR logic, (4) add a manager method that returns in-stock books.
# The TODOs below already have working defaults so this runs clean.
class Author:
def __init__(self, name):
self.name = name
class Book:
def __init__(self, title, author, price, in_stock, genre):
self.title = title
self.author = author
self.price = price
self.in_stock = in_stock
self.genre = genre
class BookManager:
def __init__(self, books):
self.books = books
# TODO: a custom manager method -> return only in-stock books.
def in_stock(self):
return [b for b in self.books if b.in_stock] # TODO: keep this filter
rowling = Author("Rowling")
asimov = Author("Asimov")
books = [
Book("Foundation", asimov, 12.0, True, "scifi"),
Book("I, Robot", asimov, 9.0, False, "scifi"),
Book("Tales", rowling, 20.0, True, "fantasy"),
]
manager = BookManager(books)
# F-style update: raise every price by 10% (like F("price") * 1.1)
for b in books:
b.price = round(b.price * 1.1, 2) # TODO: apply the 10% increase
# Q-style OR filter: genre == "scifi" OR price < 15
matched = [b.title for b in books if b.genre == "scifi" or b.price < 15]
print("In stock:", [b.title for b in manager.in_stock()])
print("After raise:", [(b.title, b.price) for b in books])
print("scifi OR <15:", matched)
# EXPECTED:
# In stock: ['Foundation', 'Tales']
# After raise: [('Foundation', 13.2), ('I, Robot', 9.9), ('Tales', 22.0)]
# scifi OR <15: ['Foundation', 'I, Robot']
# ✅ Expected output:
# In stock: ['Foundation', 'Tales']
# After raise: [('Foundation', 13.2), ('I, Robot', 9.9), ('Tales', 22.0)]
# scifi OR <15: ['Foundation', 'I, Robot']A fuller version that adds a second author and a fourth book, gives the manager extra query helpers (by_genre and a Q-style cheap_or_scifi), and finishes with a per-genre aggregation:
# CHECKPOINT BUILD (full solution): a small Bookstore domain.
# We model Author and Book, expose a custom manager with query helpers,
# then run F-style, Q-style, and aggregation operations like the ORM.
class Author:
def __init__(self, name):
self.name = name
def __str__(self):
return self.name
class Book:
def __init__(self, title, author, price, in_stock, genre):
self.title = title
self.author = author
self.price = price
self.in_stock = in_stock
self.genre = genre
def __str__(self):
return self.title
# A custom manager that bundles reusable queries (like Book.objects.in_stock()).
class BookManager:
def __init__(self, books):
self.books = books
def in_stock(self):
return [b for b in self.books if b.in_stock]
def by_genre(self, genre):
return [b for b in self.books if b.genre == genre]
# Q-style OR filter helper
def cheap_or_scifi(self, limit):
return [b for b in self.books if b.genre == "scifi" or b.price < limit]
asimov = Author("Asimov")
rowling = Author("Rowling")
books = [
Book("Foundation", asimov, 12.0, True, "scifi"),
Book("I, Robot", asimov, 9.0, False, "scifi"),
Book("Tales", rowling, 20.0, True, "fantasy"),
Book("Beedle", rowling, 7.0, True, "fantasy"),
]
manager = BookManager(books)
# 1. Custom manager method: only in-stock books
print("In stock:", [str(b) for b in manager.in_stock()])
# 2. F-style update: bump every price by 10 percent
for b in books:
b.price = round(b.price * 1.1, 2)
print("New prices:", [(b.title, b.price) for b in books])
# 3. Q-style OR filter: scifi OR price < 15
print("scifi OR <15:", [b.title for b in manager.cheap_or_scifi(15)])
# 4. Aggregation/annotation: count and average price per genre
groups = {}
for b in books:
g = groups.setdefault(b.genre, {"count": 0, "total": 0.0})
g["count"] += 1
g["total"] += b.price
for genre in sorted(groups):
g = groups[genre]
avg = round(g["total"] / g["count"], 2)
print(genre, "count:", g["count"], "avg:", avg)🎯 Your Turn: Field Rules, Meta Ordering, a Filter
The build challenge covered managers, F, and Q. This one covers three other tools from the module: a field's blank= rule (whether a form may leave it empty), Meta.ordering with its leading minus sign, and a registered template filter — all as plain Python you can run here. Fill the three blanks, then run and compare with the expected output.
# 🎯 YOUR TURN — a Book model's rules in plain Python, combining three ideas
# from this module: field validation with blank=, Meta ordering, and a
# custom template filter. Fill in the three ___ blanks, then press Run.
# 1) FIELD RULES: title is required (blank=False); subtitle is optional.
# A field with blank=False fails form validation when it is empty.
FIELDS = {"title": {"blank": False}, "subtitle": {"blank": ___}} # 👉 replace ___ with True
def validate(book):
errors = []
for name, rules in FIELDS.items():
if book.get(name, "") == "" and not rules["blank"]:
errors.append(f"{name}: This field cannot be blank.")
return errors
print(validate({"title": "Dune", "subtitle": ""})) # subtitle may be blank
print(validate({"title": "", "subtitle": ""})) # title may not
# 2) META ORDERING: class Meta: ordering = ["-price"] means highest price
# first. The leading minus sign flips the direction.
books = [
{"title": "Dune", "price": 15},
{"title": "Beedle", "price": 7},
{"title": "Neuromancer", "price": 12},
]
def apply_ordering(rows, ordering):
field = ordering.lstrip("-")
return sorted(rows, key=lambda r: r[field], reverse=ordering.startswith("-"))
META_ORDERING = ___ # 👉 replace ___ with "-price"
print([b["title"] for b in apply_ordering(books, META_ORDERING)])
# 3) TEMPLATE FILTER: @register.filter stores the function under its name so
# {{ price|currency }} can find it.
FILTERS = {}
def register_filter(func): # stands in for @register.filter
FILTERS[func.__name__] = func
return func
@register_filter
def currency(value):
return f"£{value:.2f}"
def render(value, filter_name): # what {{ value|filter_name }} does
return FILTERS[filter_name](value)
print(render(15, ___)) # 👉 replace ___ with "currency"
# ✅ Expected output:
# []
# ['title: This field cannot be blank.']
# ['Dune', 'Neuromancer', 'Beedle']
# £15.00📝 Checkpoint Quiz
Try to answer each in your head first, then expand to check.
A: makemigrations reads your models and writes new migration files describing the change — it only plans, and never touches the database. migrate takes those files and actually applies them to the database, creating or altering tables, then records each one as applied.
A: null=True is database-level: it lets the column store SQL NULL. blank=True is validation-level: it lets forms accept an empty value. They are independent, and for optional text you usually want blank=True without null=True so empty strings are stored rather than NULLs.
A: Use a Q object to combine filter conditions with OR/AND/NOT, e.g. Q(a) | Q(b). Use an F expression to reference a column on the database side so you can compare or update one field against another — like F("price") * 1.1 — without loading the row into Python.
A: aggregate() collapses the whole QuerySet into a single dictionary of summary values (one grand total). annotate() attaches a computed value to each row, and with values() it produces one summarised row per group — a group-by.
A: N+1 happens when you fetch a list (1 query) and then touch a related object per row (N more queries). select_related fixes it for ForeignKey/OneToOne relations by JOINing them into the original query, so all the data arrives at once instead of one query per row.
A: A custom manager names reusable queries as methods on the model, so a long filter becomes a readable Book.objects.in_stock(). It keeps business rules beside the model and out of your views, and can also change the default set of rows the model returns.
❓ Frequently Asked Questions
Checkpoint complete — the models and ORM toolkit is yours!
You recapped migrations, fields and Meta, model methods, QuerySets with Q and F, aggregation and annotation, the N+1 fix with select_related/prefetch_related, custom managers, and custom template tags — then modelled a bookstore and worked the quiz.
Practice quiz
What does makemigrations do?
- Applies schema changes directly to the database
- Deletes old migration files
- Writes versioned migration files describing model changes, without touching the database
- Runs the SQL in each migration
Answer: Writes versioned migration files describing model changes, without touching the database. makemigrations only plans the change into a file; migrate applies it to the database.
What is the difference between null=True and blank=True?
- null=True is database-level (allows SQL NULL); blank=True is validation-level (allows empty forms)
- They are identical aliases
- null=True controls forms; blank=True controls the database
- Both only affect the admin site
Answer: null=True is database-level (allows SQL NULL); blank=True is validation-level (allows empty forms). null governs the database column; blank governs form validation. They are independent.
Which tool combines filter conditions with OR logic?
- An F expression
- values()
- select_related
- A Q object
Answer: A Q object. Q objects let you combine conditions with | (OR), & (AND), and ~ (NOT).
What is an F expression used for?
- Combining filters with OR
- Referencing a column on the database side to compare or update fields against each other
- Grouping rows for aggregation
- Declaring a foreign key
Answer: Referencing a column on the database side to compare or update fields against each other. F('price') * 1.1 updates a field using its own DB value without loading the row into Python.
How does aggregate() differ from annotate()?
- aggregate() collapses a QuerySet to a single dict of totals; annotate() attaches a value to each row or group
- aggregate() adds a column per row; annotate() returns one number
- They produce identical output
- annotate() only works on a single model instance
Answer: aggregate() collapses a QuerySet to a single dict of totals; annotate() attaches a value to each row or group. aggregate gives one grand total; annotate gives a per-row or, with values(), a per-group figure.
What is the N+1 query problem?
- A migration that runs N+1 times
- A database with N+1 tables
- Fetching a list in one query, then running one extra query per row when touching a relation
- A bug in the Paginator
Answer: Fetching a list in one query, then running one extra query per row when touching a relation. N+1 is one query for the list plus one per row; select_related/prefetch_related fix it.
Which relation type should select_related be used for?
- ManyToMany relations
- Forward ForeignKey and OneToOne relations
- Reverse ForeignKey relations
- Unrelated models
Answer: Forward ForeignKey and OneToOne relations. select_related JOINs single-valued FK/OneToOne relations; prefetch_related handles M2M and reverse FK.
What does a custom Manager give you?
- Automatic database backups
- A replacement for migrations
- Faster template rendering
- Named, reusable queries as methods on the model, e.g. Book.objects.in_stock()
Answer: Named, reusable queries as methods on the model, e.g. Book.objects.in_stock(). A custom manager keeps reusable query logic beside the model and out of your views.
Where does the default ordering of a model belong?
- In the __str__ method
- In the inner class Meta via the ordering attribute
- In the migration file only
- In settings.py
Answer: In the inner class Meta via the ordering attribute. class Meta configures model-level options like ordering, verbose_name, and constraints.
How do you register a custom template filter?
- @app.filter
- @template.tag
- @register.filter
- @filter.register
Answer: @register.filter. @register.filter registers a function that transforms a single value, e.g. {{ price|currency }}.
Continue this course
- Previous: Custom Template Tags & Filters
- Next: Pagination with Paginator — Split long result lists into pages with Django's Paginator
- Quick reference: Django cheat sheet
Frequently asked questions
What is the difference between aggregate and annotate?
aggregate() collapses an entire QuerySet down to a single dictionary of summary values — for example the average price of every book. annotate() instead attaches a computed value to each row (or to each group when combined with values()), so you get one result per object or per group rather than one number for the whole table. Reach for aggregate when you want a grand total, and annotate when you want a per-row or per-group figure.
When should I use select_related versus prefetch_related?
Use select_related for forward ForeignKey and OneToOne relations: it performs a SQL JOIN and pulls the related rows in the same query, which is ideal for single-valued relationships. Use prefetch_related for ManyToMany and reverse ForeignKey relations: it runs a second query and joins the results in Python, which is how you efficiently load collections. Both exist to defeat the N+1 query problem, just for different relationship shapes.
What does a custom manager give me that a plain QuerySet does not?
A custom manager lets you name and reuse common queries as methods on the model itself, so Book.objects.in_stock() reads clearly and lives in one place. It removes duplicated filter logic from your views, keeps business rules close to the model, and can change the default set of rows the model returns. Pairing it with a custom QuerySet class also keeps those helpers chainable.
Is this checkpoint graded or required?
No. A checkpoint is a self-assessment milestone, not a graded exam. It exists so you can pause, recall what you learned across the models and ORM lessons, and prove to yourself that the ideas stuck before you move on. Work the build challenge and quiz at your own pace, and revisit any earlier lesson whose concept still feels shaky.