Lazy Evaluation
QuerySets are lazy. SQL runs only on evaluation.
Django ORM is one of the most important backend skills. As your project grows, query quality decides performance, correctness, and maintainability.
Django ORM is:
Django ORM is not:
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.
This mental model helps you debug:
| Component | Purpose |
|---|---|
| Model | Table mapping |
| Manager | Entry point |
| QuerySet | Lazy query object |
| Field | Column definition |
| Lookup | WHERE clause behavior |
| Expression | SQL expression building |
qs = Product.objects.all() # Query is not executed yetThe query runs only when evaluated, for example when:
qslist(qs)if qs:)qs = Product.objects.filter(is_active=True)print(qs) # often triggers evaluationproducts = list(qs) # definitely evaluatesReset operations are useful in development, especially during early schema redesign.
python manage.py flushThis removes data but keeps schema and migration history.
rm db.sqlite3rm -rf app/migrationspython manage.py makemigrationspython manage.py migrateA Manager is the entry point to database operations.
Product.objectsobjects is the default Manager.
A QuerySet represents a database query and can be chained.
qs = Product.objects.filter(price__gt=100)| Manager | QuerySet |
|---|---|
| Entry point | Query builder |
| Usually one default per model | Chainable and immutable |
Example: objects | Example: 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:
DoesNotExistMultipleObjectsReturnedfrom django.core.exceptions import ObjectDoesNotExist
try: product = Product.objects.get(id=1)except Product.DoesNotExist: product = NoneProduct.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| Lookup | Meaning |
|---|---|
exact | = |
iexact | case-insensitive exact |
contains | LIKE %x% |
icontains | case-insensitive contains |
in | IN (...) |
gt / gte | > / >= |
lt / lte | < / <= |
range | BETWEEN |
isnull | IS NULL |
startswith | LIKE x% |
istartswith | case-insensitive startswith |
endswith | LIKE %x |
iendswith | case-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:
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:
Product.objects.defer('description')This avoids loading large columns immediately.
Product.objects.only('name', 'price')This loads only selected fields first.
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.
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.
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| aggregate | annotate |
|---|---|
| Whole result summary | Per-row computed values |
| Returns one dictionary | Returns QuerySet |
from django.db.models.functions import Length
Product.objects.annotate(name_length=Length('name'))from django.db.models import F, Func, Valuefrom 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.
LengthUpperLowerConcatExtractYearUse 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:
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 runslist(qs) # uses cached results in same queryset objectCache is effectively bypassed when:
filter, exclude, etc.)qs = Product.objects.all()qs2 = qs.filter(price__gt=100) # new queryset, new SQLproduct = Product.objects.create(name='Phone', price=1000)product = Product()product.name = 'Phone'product.price = 1000product.save()Product.objects.bulk_create([ Product(name='Phone', price=1000), Product(name='Laptop', price=2000),])bulk_create is fast, but by default:
save() logic is not called per objectProduct.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 = 200product.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.
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.Use these rules:
F() for atomic counter and stock updates.transaction.atomic() for multi-step writes.get() then save() when model hooks must run.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.