Checkpoint: Models & the ORM

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.

Learn Checkpoint: Models & the ORM in our free Django course — a beginner-friendly interactive lesson with worked examples, a practice exercise and a quick…

Part of the free Django course at LearnCodingFast — hands-on lessons with examples you run in your browser, 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.

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.

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.

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.

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.

When the template language runs out of built-ins, you register your own. A @register.filter transforms a single value ( {'{ }'} ), and a @register.simple_tag or inclusion tag computes richer output — keeping presentation logic reusable and out of your views.

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.

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.

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.

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:

⏱ Timed 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.

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.

🚀 Up next: Pagination — slice large QuerySets into pages so your list views stay fast and friendly.

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 }}.