QuerySet Deep Dive: Q, F & Lookups

Reviewed & published by Brayan K

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.

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

What You'll Learn in This Lesson

1 Field Lookups & Chaining filter() / exclude()

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.

# Real Django ORM lookups
from blog.models import Post

# >= and <= comparisons
Post.objects.filter(views__gte=100)          # views >= 100
Post.objects.filter(views__lte=500)          # views <= 500

# case-insensitive substring match
Post.objects.filter(title__icontains="django")

# membership and NULL tests
Post.objects.filter(status__in=["published", "featured"])
Post.objects.filter(published_at__isnull=True)   # never published

# chaining: keep popular posts, then drop drafts/archived
Post.objects.filter(views__gte=100).exclude(status__in=["draft", "archived"])

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

# Post.objects.filter(views__gte=100).exclude(status__in=["draft", "archived"])
# becomes roughly:
SELECT * FROM blog_post
WHERE views >= 100
  AND NOT (status IN ('draft', 'archived'));
# Field lookups + chaining filter()/exclude() turn into row-by-row tests.
# Here we filter a list of dicts with the SAME semantics Django uses for
# __gte, __lte, __icontains and __in, then chain filter then exclude.

posts = [
    {"title": "Django Tips", "views": 250, "status": "published"},
    {"title": "Vite Basics", "views": 80,  "status": "draft"},
    {"title": "Deep Django", "views": 500, "status": "published"},
    {"title": "React Notes", "views": 120, "status": "archived"},
]

def matches(row, lookups):
    for key, value in lookups.items():
        field, _, op = key.partition("__")
        cell = row[field]
        if op == "gte" and not (cell >= value):
            return False
        if op == "lte" and not (cell <= value):
            return False
        if op == "icontains" and value.lower() not in str(cell).lower():
            return False
        if op == "in" and cell not in value:
            return False
    return True

def filter_rows(rows, **lookups):
    return [r for r in rows if matches(r, lookups)]

def exclude_rows(rows, **lookups):
    return [r for r in rows if not matches(r, lookups)]

# Post.objects.filter(views__gte=100).exclude(status__in=["draft", "archived"])
result = exclude_rows(
    filter_rows(posts, views__gte=100),
    status__in=["draft", "archived"],
)

for row in result:
    print(row["title"], row["views"])

# EXPECTED:
# Django Tips 250
# Deep Django 500

# ✅ Expected output:
# Django Tips 250
# Deep Django 500

2 Q Objects for OR, AND & NOT

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.

# Real Django Q objects
from django.db.models import Q
from blog.models import Post

# OR: popular OR featured
Post.objects.filter(Q(views__gte=100) | Q(featured=True))

# AND (the same as comma-separated kwargs, but explicit)
Post.objects.filter(Q(views__gte=100) & Q(status="published"))

# NOT: everything that is NOT a draft
Post.objects.filter(~Q(status="draft"))

# Grouped logic: (popular OR featured) AND published
Post.objects.filter(
    (Q(views__gte=100) | Q(featured=True)) & Q(status="published")
)

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

# Build a condition based on user input, then apply it
condition = Q(status="published")
if request.GET.get("popular"):
    condition &= Q(views__gte=100)
if request.GET.get("featured"):
    condition |= Q(featured=True)

results = Post.objects.filter(condition)
# A Q object is a reusable bundle of conditions you can combine with
# | (OR), & (AND) and ~ (NOT). We mimic that by wrapping a test function
# and overloading those same operators over a list of dicts.

posts = [
    {"title": "Django Tips", "views": 250, "featured": False},
    {"title": "Vite Basics", "views": 80,  "featured": True},
    {"title": "Deep Django", "views": 500, "featured": False},
    {"title": "React Notes", "views": 50,  "featured": False},
]

class Q:
    def __init__(self, test):
        self.test = test
    def __or__(self, other):
        return Q(lambda r: self.test(r) or other.test(r))
    def __and__(self, other):
        return Q(lambda r: self.test(r) and other.test(r))
    def __invert__(self):
        return Q(lambda r: not self.test(r))

# Post.objects.filter(Q(views__gte=100) | Q(featured=True))
condition = Q(lambda r: r["views"] >= 100) | Q(lambda r: r["featured"])

for row in posts:
    if condition.test(row):
        print(row["title"])

# EXPECTED:
# Django Tips
# Vite Basics
# Deep Django

# ✅ Expected output:
# Django Tips
# Vite Basics
# Deep Django

3 F Expressions & QuerySet Laziness

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.

# Real Django F expressions
from django.db.models import F
from shop.models import Product

# Field-to-field comparison: price greater than cost
Product.objects.filter(price__gt=F("cost"))

# Atomic increment — runs as one UPDATE, no race condition
Product.objects.update(views=F("views") + 1)

# Arithmetic across fields in an update
Product.objects.update(price=F("cost") * 2)

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:

# Nothing has touched the database yet — qs is just a query description
qs = Post.objects.filter(views__gte=100).exclude(status="draft")

# Evaluation happens when you:
for post in qs:        # 1. iterate
    print(post.title)
list(qs)               # 2. call list()
qs[0]                  # 3. index / slice
bool(qs)               # 4. test truthiness
# Each of those sends exactly ONE SQL query.
# F() references a column's CURRENT value in the database, so the update
# happens atomically inside one SQL statement. Compare these two rows:
# one uses field-to-field comparison, the other an atomic increment.

products = [
    {"name": "Keyboard", "price": 40, "cost": 30, "views": 10},
    {"name": "Monitor",  "price": 200, "cost": 220, "views": 5},
    {"name": "Mouse",    "price": 25, "cost": 25, "views": 8},
]

# Product.objects.filter(price__gt=F("cost"))  -> price greater than cost
print("Profitable (price > cost):")
for p in products:
    if p["price"] > p["cost"]:
        print(" ", p["name"])

# Product.objects.update(views=F("views") + 1)  -> atomic increment
for p in products:
    p["views"] = p["views"] + 1

print("Views after F increment:")
for p in products:
    print(" ", p["name"], p["views"])

# EXPECTED:
# Profitable (price > cost):
#   Keyboard
# Views after F increment:
#   Keyboard 11
#   Monitor 6
#   Mouse 9

# ✅ Expected output:
# Profitable (price > cost):
#   Keyboard
# Views after F increment:
#   Keyboard 11
#   Monitor 6
#   Mouse 9

🎯 Your Turn: Force a Lazy QuerySet to Evaluate

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

# 🎯 YOUR TURN: a QuerySet is LAZY — it does not hit the database until
# you iterate, slice, or call list() on it. Replace ___ so the query
# only "runs" when we force evaluation with list().

queries_run = []

class LazyQuerySet:
    def __init__(self, rows):
        self.rows = rows
    def filter(self, predicate):
        # filtering just records intent, it does NOT evaluate yet
        return LazyQuerySet([r for r in self.rows if predicate(r)])
    def evaluate(self):
        queries_run.append("SELECT ...")   # the DB hit happens here
        return list(self.rows)

qs = LazyQuerySet([1, 2, 3, 4, 5]).filter(lambda n: n > 2)
print("Queries so far:", len(queries_run))   # still lazy -> 0

results = qs.___()    # force evaluation
print("Results:", results)
print("Queries after list():", len(queries_run))

# EXPECTED OUTPUT:
# Queries so far: 0
# Results: [3, 4, 5]
# Queries after list(): 1

Common Errors (And How to Fix Them)

❌ Using OR by passing two filter() calls

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.

❌ "Related Field got invalid lookup: gte"

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

📋 Quick Reference

ToolWhat It Does
views__gte=100Keep rows where views >= 100 (also __lte, __gt, __lt)
title__icontains="x"Case-insensitive substring match on a field
status__in=[...]Match any value in the list; __isnull tests for NULL
Q(a=1) | Q(b=2)OR logic; use & for AND and ~ for NOT
F("views") + 1Reference a field's DB value for atomic updates

🏆 Mini Challenge: Combine Lookups, Q & F

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.

# 🏆 Mini Challenge: combine field lookups, an OR (Q) and an F-style
# atomic update. Find posts that are featured OR have 100+ views, then
# bump every matching post's views by 10 the way F("views") + 10 would.

posts = [
    {"title": "Django Tips", "views": 250, "featured": False},
    {"title": "Vite Basics", "views": 80,  "featured": True},
    {"title": "Deep Django", "views": 500, "featured": False},
    {"title": "React Notes", "views": 50,  "featured": False},
]

def featured_or_popular(row):
    return row["featured"] or row["views"] >= 100

selected = [p for p in posts if featured_or_popular(p)]

# Atomic F-style increment on the selected rows
for p in selected:
    p["views"] = p["views"] + 10

print("Updated", len(selected), "posts")
for p in selected:
    print(p["title"], "->", p["views"])

# EXPECTED:
# Updated 3 posts
# Django Tips -> 260
# Vite Basics -> 90
# Deep Django -> 510

❓ Frequently Asked Questions

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.

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.

Continue this course

Frequently asked questions

What is a field lookup in Django?

A field lookup is the double-underscore suffix you add to a field name inside filter(), exclude(), or get() to describe how to compare it — for example views__gte=100 means 'views greater than or equal to 100'. Django translates the lookup into the matching SQL WHERE clause, so __gte becomes >=, __icontains becomes a case-insensitive LIKE, __in becomes IN (...), and __isnull becomes IS NULL. Lookups are how you express conditions without writing raw SQL.

When should I use a Q object instead of plain filter() arguments?

Reach for a Q object whenever you need OR logic or grouped/negated conditions, because the keyword arguments to filter() are always combined with AND. Q lets you write Q(a=1) | Q(b=2) for OR, ~Q(c=3) for NOT, and nest them with parentheses for complex boolean expressions. For straightforward AND conditions, the simpler keyword form is fine and more readable.

What does an F expression actually do?

An F expression refers to the value of a model field directly in the database, so the operation runs in SQL instead of pulling the row into Python. That makes F('price') > F('cost') a field-to-field comparison and .update(views=F('views') + 1) an atomic increment that avoids race conditions between concurrent requests. Because the work happens in the database, F is both faster and safe under concurrency.

Why is a QuerySet called lazy?

A QuerySet is lazy because building and chaining it — calling filter(), exclude(), order_by() and so on — does not touch the database. Django waits until you actually need the data, which happens when you iterate over the QuerySet, slice it, call list() on it, or otherwise force evaluation. This lets you compose a complex query in several steps while still sending just one efficient SQL statement.