Skip to content

Django Queries

Django ORM is one of the most important backend skills. As your project grows, query quality decides performance, correctness, and maintainability.

Django ORM is:

  • an Object Relational Mapper
  • a query builder
  • a lazy execution system
  • a transaction-aware abstraction layer

Django ORM is not:

  • magic
  • a replacement for SQL knowledge

In practical terms, ORM lets you write Python code, but the database still runs SQL under the hood. Strong Django developers know both ORM style and SQL thinking.

flowchart LR A[Python Object Access] --> B[QuerySet<br/>Lazy, Not Executed Yet] B --> C[SQL Compiler] C --> D[Database Engine] D --> E[Result Rows] E --> F[Model Instances or Dict/Tuple Data]

This mental model helps you debug:

  • where the query is created
  • when it actually runs
  • why performance issues appear
ComponentPurpose
ModelTable mapping
ManagerEntry point
QuerySetLazy query object
FieldColumn definition
LookupWHERE clause behavior
ExpressionSQL expression building
qs = Product.objects.all() # Query is not executed yet

The query runs only when evaluated, for example when:

  • iterating over qs
  • converting to list(qs)
  • printing it in many contexts
  • slicing with evaluation
  • checking truthiness (if qs:)
qs = Product.objects.filter(is_active=True)
print(qs) # often triggers evaluation
products = list(qs) # definitely evaluates

Reset operations are useful in development, especially during early schema redesign.

Terminal window
python manage.py flush

This removes data but keeps schema and migration history.

Terminal window
rm db.sqlite3
rm -rf app/migrations
python manage.py makemigrations
python manage.py migrate

A Manager is the entry point to database operations.

Product.objects

objects is the default Manager.

A QuerySet represents a database query and can be chained.

qs = Product.objects.filter(price__gt=100)
ManagerQuerySet
Entry pointQuery builder
Usually one default per modelChainable and immutable
Example: objectsExample: filter(), exclude()
Product.objects.all()

Returns a QuerySet of all rows for that model.

Product.objects.get(id=1)

get() expects exactly one row. It raises:

  • DoesNotExist
  • MultipleObjectsReturned
from django.core.exceptions import ObjectDoesNotExist
try:
product = Product.objects.get(id=1)
except Product.DoesNotExist:
product = None
Product.objects.order_by('id').first()
Product.objects.order_by('id').last()

These return one object or None, making them safer for optional access.

Product.objects.filter(price__gt=100)
Product.objects.exclude(is_active=False)

Use this pattern:

field__lookup=value
LookupMeaning
exact=
iexactcase-insensitive exact
containsLIKE %x%
icontainscase-insensitive contains
inIN (...)
gt / gte> / >=
lt / lte< / <=
rangeBETWEEN
isnullIS NULL
startswithLIKE x%
istartswithcase-insensitive startswith
endswithLIKE %x
iendswithcase-insensitive endswith
Product.objects.filter(name__icontains='phone', price__range=(100, 1000))
Product.objects.filter(category_id__in=[1, 2, 3], deleted_at__isnull=True)

Default chained filters use AND logic. Q objects let you use OR, AND, and NOT explicitly.

from django.db.models import Q
Product.objects.filter(
Q(price__gt=500) | Q(stock__lt=10)
)
Product.objects.filter(~Q(is_active=True))
from django.db.models import Q
query = Q()
if min_price is not None:
query &= Q(price__gte=min_price)
if max_price is not None:
query &= Q(price__lte=max_price)
if keyword:
query &= Q(name__icontains=keyword) | Q(description__icontains=keyword)
products = Product.objects.filter(query)

F expressions perform operations at the database level. This prevents race conditions caused by read-modify-write in Python.

from django.db.models import F
Product.objects.filter(id=1).update(stock=F('stock') - 1)

Why this matters:

  • atomic DB-side update
  • safer under concurrency
  • avoids stale in-memory values
Product.objects.order_by('price')
Product.objects.order_by('-price')
Product.objects.order_by('?')
Product.objects.all()[:10]

This usually compiles to SQL with LIMIT 10.

Paginator is built on slicing and works best with stable ordering.

from django.core.paginator import Paginator
qs = Product.objects.order_by('-created_at')
paginator = Paginator(qs, 20)
page_1 = paginator.get_page(1)
Product.objects.values('id', 'name')
Product.objects.values('id', 'name', 'category__name')

Returns dictionaries.

Product.objects.values_list('name', flat=True)
Product.objects.values_list('id', 'name')

Returns tuples (or a flat list for one field with flat=True).

Product.objects.values('category').distinct()

Use cases:

  • report endpoints
  • lightweight API responses
  • performance optimization when full model objects are not needed
Product.objects.defer('description')

This avoids loading large columns immediately.

Product.objects.only('name', 'price')

This loads only selected fields first.

Section titled “select_related for ForeignKey and OneToOne”
Order.objects.select_related('user')
Order.objects.select_related('user', 'product')
Order.objects.select_related('user__profile')
# here profile is a OneToOne field on User so we can use double underscore to select it in the same query. We can also select multiple levels of related objects in the same query using double underscores.

select_related uses SQL JOIN and loads related single-value objects in one query.

Section titled “prefetch_related for ManyToMany and reverse relations”
Category.objects.prefetch_related('products')
Order.objects.prefetch_related('items', 'items__product')
# here order have a reverse relation to items and items have a foreign key relation to product so we can prefetch both of them in the same query using double underscores.

prefetch_related runs separate queries and merges results in Python.

flowchart TD A[Load categories] --> B[1 query] B --> C[Loop categories] C --> D[Each category loads products separately] D --> E[N extra queries] E --> F[Total: 1 + N queries]

Use select_related or prefetch_related to avoid N+1 issues.

from django.db.models import Avg, Count, Max, Min, Sum
Product.objects.aggregate(
avg_price=Avg('price'),
total_stock=Sum('stock'),
total_items=Count('id'),
max_price=Max('price'),
min_price=Min('price'),
)

aggregate() returns a dictionary, not a QuerySet.

annotate() adds computed fields per row.

from django.db.models import Count
Category.objects.annotate(product_count=Count('products'))
Expression
|__ Value
|__ F
|__ Aggregate
|__ Func
aggregateannotate
Whole result summaryPer-row computed values
Returns one dictionaryReturns QuerySet
from django.db.models.functions import Length
Product.objects.annotate(name_length=Length('name'))
from django.db.models import F, Func, Value
from django.db.models.functions import Concat
Product.objects.annotate(
full_name=Func(F('first_name'), Value(' '), F('last_name'), function='CONCAT')
)
Product.objects.annotate(
full_name=Concat('first_name', Value(' '), 'last_name')
)

Use Value(' ') for constants. F(' ') would incorrectly look for a field named space.

  • Length
  • Upper
  • Lower
  • Concat
  • ExtractYear

Use Func for database-specific functions that Django does not expose as built-in helpers.

Grouping in ORM is often done using values(...).annotate(...).

from django.db.models import Count
Product.objects.values('category').annotate(total=Count('id'))

This compiles to SQL with GROUP BY category.

Use ExpressionWrapper when Django cannot infer expression output type.

from django.db.models import DecimalField, ExpressionWrapper, F
Product.objects.annotate(
total_price=ExpressionWrapper(
F('price') * F('quantity'),
output_field=DecimalField(max_digits=12, decimal_places=2),
)
)

When using GenericForeignKey, query by ContentType and target object id.

from django.contrib.contenttypes.models import ContentType
ct = ContentType.objects.get_for_model(Product)
Comment.objects.filter(content_type=ct, object_id=product_id)

Trade-offs:

  • no normal foreign key DB constraint
  • joins are harder
  • can be slower than standard relations

Custom managers encapsulate reusable query logic and reduce duplication.

from django.db import models
class ActiveManager(models.Manager):
def active(self):
return self.filter(is_active=True)
class Product(models.Model):
is_active = models.BooleanField(default=True)
objects = models.Manager()
active_objects = ActiveManager()

Usage:

Product.active_objects.active()

QuerySet results are cached after evaluation.

qs = Product.objects.all()
list(qs) # query runs
list(qs) # uses cached results in same queryset object

Cache is effectively bypassed when:

  • a new queryset is created
  • queryset chain changes (filter, exclude, etc.)
  • data changes externally in database
qs = Product.objects.all()
qs2 = qs.filter(price__gt=100) # new queryset, new SQL
product = Product.objects.create(name='Phone', price=1000)
product = Product()
product.name = 'Phone'
product.price = 1000
product.save()
Product.objects.bulk_create([
Product(name='Phone', price=1000),
Product(name='Laptop', price=2000),
])

bulk_create is fast, but by default:

  • model save() logic is not called per object
  • signals are not fired in the usual per-row way
Product.objects.filter(id=1).update(price=200)

update() writes directly in SQL and does not call model save().

product = Product.objects.get(pk=1)
product.price = 200
product.save()

Use fetch-then-save when you need validation, custom save logic, or signals.

Product.objects.filter(id=1).delete()

Deletes obey relation on_delete behavior (CASCADE, PROTECT, etc.).

Always review cascade impact before production deletes.

Transactions protect consistency across multiple related writes.

from django.db import transaction
with transaction.atomic():
order.save()
payment.save()

If an exception occurs, all changes inside atomic() rollback.

Nested transactions use savepoints internally.

flowchart LR A[Begin atomic block] --> B[Write order] B --> C[Write payment] C --> D{Any exception?} D -- No --> E[Commit] D -- Yes --> F[Rollback]

ORM should be default, but raw SQL is valid for complex and DB-specific cases.

Product.objects.raw(
'SELECT * FROM product WHERE price > %s',
[100],
)
from django.db import connection
with connection.cursor() as cursor:
cursor.execute('SELECT COUNT(*) FROM product')
row = cursor.fetchone()

Use these in this order:

  • values() or values_list() when full model instances are not required.
  • select_related() for FK and OneToOne relations.
  • prefetch_related() for ManyToMany and reverse relations.
  • Avoid N+1 queries by checking Django Debug Toolbar or query logs.

Lazy Evaluation

QuerySets are lazy. SQL runs only on evaluation.

Immutability

QuerySet chaining creates new QuerySets.

Related Loading

Use select_related for joins and prefetch_related for separate query merge.

Q and F

Q handles OR/NOT logic, F handles DB-side atomic expressions.

annotate vs aggregate

annotate adds per-row computed values, aggregate returns whole-query summary.

Transactions

Use atomic blocks for consistency and rollback safety.

Custom Managers

Put reusable query logic in managers/querysets for clean architecture.

Raw SQL

Use only when needed and always parameterize inputs.