Django ORM cheatsheet.

Basics

Post.objects.all()
Post.objects.first()
Post.objects.last()
Post.objects.count()
Post.objects.get(id=1)              # raises if 0 or multiple
Post.objects.filter(published=True)
Post.objects.exclude(deleted=True)
Post.objects.exists()               # True if any

Field lookups

Post.objects.filter(title__exact="x")
Post.objects.filter(title__iexact="x")            # case-insensitive
Post.objects.filter(title__contains="x")
Post.objects.filter(title__icontains="x")
Post.objects.filter(title__startswith="x")
Post.objects.filter(title__endswith="x")
Post.objects.filter(title__regex=r"^[A-Z]")
Post.objects.filter(views__gt=100)
Post.objects.filter(views__gte=100)
Post.objects.filter(views__lt=100)
Post.objects.filter(views__lte=100)
Post.objects.filter(views__range=(10, 100))
Post.objects.filter(id__in=[1, 2, 3])
Post.objects.filter(slug__isnull=False)
Post.objects.filter(published_at__year=2026)
Post.objects.filter(published_at__date=date(2026, 1, 1))

Chaining

Post.objects.filter(published=True).exclude(views=0).order_by("-views")[:10]

QuerySets are lazy — query runs on iterate/index/list.

Order / slice

Post.objects.order_by("title")
Post.objects.order_by("-views")
Post.objects.order_by("title", "-views")
Post.objects.order_by("?")        # random (slow)

Post.objects.all()[:10]
Post.objects.all()[10:20]

Q objects (OR / NOT)

from django.db.models import Q

Post.objects.filter(Q(views__gt=100) | Q(comments__gt=10))
Post.objects.filter(Q(title__icontains="x") & ~Q(deleted=True))

F objects (column refs)

from django.db.models import F

# Compare two columns
Post.objects.filter(views__gt=F("comments") * 10)

# Atomic update
Post.objects.filter(id=1).update(views=F("views") + 1)

Avoid race conditions on counters.

Aggregation

from django.db.models import Count, Sum, Avg, Min, Max

stats = Post.objects.aggregate(
    total=Count("id"),
    avg_views=Avg("views"),
    max_views=Max("views"),
)
# {"total": 100, "avg_views": 50.0, "max_views": 1000}

annotate (per-row)

authors = User.objects.annotate(post_count=Count("post"))
for a in authors:
    print(a.username, a.post_count)
posts = Post.objects.annotate(comment_count=Count("comments"))

Relationships

class Post(models.Model):
    author = models.ForeignKey(User, on_delete=models.CASCADE)
    tags = models.ManyToManyField(Tag)

# Forward
post.author                    # one query
post.tags.all()                # one query

# Reverse
user.post_set.all()
# Or with related_name:
class Post(models.Model):
    author = models.ForeignKey(User, related_name="posts", on_delete=models.CASCADE)

user.posts.all()

N+1 problem

# BAD: 1 + N queries
for post in Post.objects.all():
    print(post.author.name)    # extra query per post

# GOOD: 1 query with JOIN
for post in Post.objects.select_related("author"):
    print(post.author.name)
Post.objects.select_related("author")
Post.objects.select_related("author", "category")
Post.objects.select_related("author__profile")    # nested
# BAD
for post in Post.objects.all():
    for tag in post.tags.all():        # extra query per post
        ...

# GOOD
Post.objects.prefetch_related("tags")

Prefetch (filtered prefetch)

from django.db.models import Prefetch

Post.objects.prefetch_related(
    Prefetch("comments", queryset=Comment.objects.filter(approved=True))
)

Subqueries

from django.db.models import Subquery, OuterRef

latest = Comment.objects.filter(post=OuterRef("pk")).order_by("-created_at")
posts = Post.objects.annotate(latest_comment=Subquery(latest.values("id")[:1]))

values / values_list

Post.objects.values("id", "title")           # dicts
Post.objects.values_list("id", "title")      # tuples
Post.objects.values_list("id", flat=True)    # flat list of ids

bulk_create / bulk_update

Post.objects.bulk_create([
    Post(title="a"),
    Post(title="b"),
])

Post.objects.bulk_update(posts, ["title"])

Much faster than save() in loop.

update / delete

Post.objects.filter(deleted=True).update(archived=True)
Post.objects.filter(views=0).delete()

Bulk operations don’t call save() or signals.

Raw SQL

Post.objects.raw("SELECT id, title FROM blog_post WHERE views > %s", [100])

from django.db import connection
with connection.cursor() as c:
    c.execute("SELECT ...", [...])
    rows = c.fetchall()

Transactions

from django.db import transaction

with transaction.atomic():
    a.save()
    b.save()
    # All-or-nothing

Decorator form:

@transaction.atomic
def update_user(...):
    ...

select_for_update

with transaction.atomic():
    user = User.objects.select_for_update().get(id=1)
    user.balance -= 10
    user.save()

Row-locks until transaction ends.

explain

print(Post.objects.filter(views__gt=100).explain())
print(Post.objects.filter(views__gt=100).query)

Django Debug Toolbar

uv add --dev django-debug-toolbar

Shows queries per request. Essential for finding N+1.

Common mistakes

  • N+1 without select_related/prefetch_related.
  • len(qs) to count — uses count(*) query. Use .count().
  • if qs: evaluates the whole queryset. Use qs.exists().
  • update() on chained select_related — unsupported.
  • Forgetting index=True on FK / filter fields → slow queries.

Read this next

If you want my ORM optimization recipes, they’re at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .