QuerySet Deep Dive: Q, F & Lookups

A QuerySet is a lazy, chainable collection of database rows that Django builds up as Python objects and only sends to the database as a single SQL query when you finally iterate, slice, or evaluate it.

Learn QuerySet Deep Dive: Q, F & Lookups in our free Django course — a beginner-friendly interactive lesson with worked examples, a practice exercise and a…

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 lesson you'll master the tools that make QuerySets powerful: field lookups like __gte and __icontains, Q objects for OR/AND/NOT logic, F expressions for field-to-field comparisons and atomic updates, and the laziness that ties it all together.

A field lookup is the double-underscore suffix you attach to a field name to say how to compare it. filter() keeps rows that match, exclude() drops them, and because every QuerySet method returns a new QuerySet you can chain them. Keyword arguments inside one call are always combined with AND .

Each lookup maps directly to a piece of the generated SQL WHERE clause:

Plain filter() arguments can only be AND-ed together, so for OR logic you wrap each condition in a Q object and combine them with | (OR), & (AND), and ~ (NOT). A Q is a reusable bundle of conditions you can build up and pass around.

You can also build a Q up dynamically and only run the query once it is complete:

An F expression refers to a field's current value in the database , so the work happens in SQL rather than Python. That unlocks two things: field-to-field comparisons (compare one column to another) and atomic updates that are safe even when many requests run at once.

The reason chaining all of this is cheap is that a QuerySet is lazy . Building it does nothing; the database is only hit when you force evaluation:

Fill in the blank so the lazy query only "runs" when you force evaluation with the method that returns its rows.

Chaining .filter(a=1).filter(b=2) gives you AND, not OR, so you get far fewer rows than expected.

✅ Fix: use a Q object — filter(Q(a=1) | Q(b=2)) — for true OR logic.

You misspelled the lookup or used a single underscore, so Django reads views_gte as a field name.

✅ Fix: use a double underscore — views__gte — between the field name and the lookup.

❌ Incrementing with obj.views += 1 loses updates

Reading into Python then saving lets two concurrent requests overwrite each other's count.

✅ Fix: use an atomic update with an F expression — .update(views=F("views") + 1) .

Put it all together: select the posts that are featured OR popular, then bump each one's views the way an F expression would in a single atomic update.

Lesson complete — you can write expressive, efficient queries!

You can now reach for field lookups like __gte and __icontains , chain filter() and exclude() , express OR/AND/NOT with Q objects, compare and atomically update fields with F expressions, and explain why a QuerySet stays lazy until it is evaluated.

🚀 Up next: Aggregation & Annotation — summarise and compute over whole QuerySets with Count, Sum, and annotate().

Practice quiz

What is a field lookup in Django?

  • A double-underscore suffix describing how to compare a field
  • A type of database index
  • A model method
  • A URL pattern

Answer: A double-underscore suffix describing how to compare a field. Lookups like views__gte are double-underscore suffixes that map to SQL comparisons.

Which lookup performs a case-insensitive substring match?

  • __contains
  • __icontains
  • __iexact
  • __startswith

Answer: __icontains. __icontains is a case-insensitive substring match (SQL LIKE, case-insensitive).

How are multiple keyword arguments inside a single filter() call combined?

  • With OR
  • Randomly
  • With AND
  • They overwrite each other

Answer: With AND. Keyword arguments in one filter() call are always combined with AND.

Which object lets you express OR logic in a query?

  • An F expression
  • A Manager
  • A lookup
  • A Q object

Answer: A Q object. Q objects combined with | express OR logic, which plain kwargs cannot.

Which operator combines two Q objects with OR?

  • |
  • &
  • ~
  • +

Answer: |. The | operator ORs two Q objects; & is AND and ~ is NOT.

What does an F expression refer to?

  • A Python variable
  • A field's current value in the database
  • A foreign key target
  • A form field

Answer: A field's current value in the database. F() references a model field's value in the database so the operation runs in SQL.

Why is .update(views=F('views') + 1) safer than reading and saving in Python?

  • It is shorter to type
  • It validates the field
  • It increments atomically in the database, avoiding race conditions
  • It skips the database

Answer: It increments atomically in the database, avoiding race conditions. The F-based update runs as one atomic SQL UPDATE, immune to concurrent overwrites.

What does ~Q(status='draft') express?

  • status equals draft
  • status is null
  • status in a list
  • NOT (status equals draft)

Answer: NOT (status equals draft). The ~ operator negates a Q object, producing a NOT condition.

When does a lazy QuerySet actually hit the database?

  • When you iterate, slice, or call list() on it
  • As soon as you call filter()
  • When the module is imported
  • Never

Answer: When you iterate, slice, or call list() on it. Building/chaining runs no SQL; evaluation happens on iteration, slicing, list(), etc.

Which lookup tests whether a field is NULL?

  • __none
  • __isnull
  • __empty
  • __null

Answer: __isnull. published_at__isnull=True matches rows where the column IS NULL.