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

📚 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: 3

2 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:

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

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.