
Why Your Django Application Gets Slower as It Grows
Your Django application works perfectly during development. Pages load quickly, API responses look good, and database queries seem harmless.
Then your application starts handling thousands of records. A dashboard that once loaded instantly takes several seconds. An API endpoint becomes slower as more users register. Database CPU usage increases, even though the application code has barely changed.
The problem might not be Django itself. It could be the way your application retrieves and processes data.
The Django ORM provides a powerful abstraction over SQL, but convenient code does not automatically produce efficient database access. A single loop can trigger hundreds of queries, an unnecessary model load can consume memory, and a missing database index can turn a simple lookup into an expensive scan.
The good news is that Django already provides tools to address many of these problems.
In this guide, we'll explore ten practical Django ORM optimization techniques, with examples you can apply to real backend applications and Django REST Framework APIs.
1. Fix N+1 queries with select_related()
Imagine you are building an API that returns recent blog posts along with their authors.
``python
posts = Post.objects.order_by("-created_at")[:100]
for post in posts:
print(post.title, post.author.username)
`
If each post accesses an author that has not already been loaded, Django may execute one query to retrieve the posts and additional queries to retrieve their authors.
For 100 posts, this can mean approximately 101 queries rather than one or a small number of queries.
This is the classic N+1 query problem.
For foreign-key and one-to-one relationships, use select_related():
`python
posts = (
Post.objects
.select_related("author")
.order_by("-created_at")[:100]
)
for post in posts:
print(post.title, post.author.username)
`
Django retrieves the related author data through SQL joins instead of fetching each author separately.
The same approach is useful for API serializers that access related objects.
`python
class PostSerializer(serializers.ModelSerializer):
author_name = serializers.CharField(
source="author.username",
read_only=True,
)
class Meta:
model = Post
fields = ["id", "title", "author_name"]
`
Pairing the serializer with select_related("author") helps prevent unnecessary queries while serializing a collection of posts.
2. Use
prefetch_related() for collections
What if you need the comments attached to each post?
A post can have many comments, so joining every comment into the primary result can create unnecessarily large result sets.
Use prefetch_related() for reverse foreign-key and many-to-many relationships:
`python
posts = (
Post.objects
.select_related("author")
.prefetch_related("comments")
.order_by("-created_at")[:100]
)
`
Django retrieves the posts and authors together, then fetches the comments in a separate query and associates them with the relevant posts.
A useful rule of thumb is:
select_related() — foreign-key and one-to-one relationships.
prefetch_related() — reverse foreign-key and many-to-many relationships.
You can also customize the prefetched data:
`python
from django.db.models import Prefetch
posts = Post.objects.prefetch_related(
Prefetch(
"comments",
queryset=Comment.objects.filter(
is_approved=True
).order_by("-created_at"),
to_attr="approved_comments",
)
)
`
Now each post exposes its filtered comments through post.approved_comments.
Important: A prefetched cache generally serves the related objects that were fetched. Calling a new query such as post.comments.filter(is_approved=True) can execute another database query. Design the prefetch to match the data your application actually uses.
3. Retrieve only the fields you need
Sometimes the problem is not the number of queries. It is the amount of data you retrieve.
Consider an endpoint that returns a list of users containing only their IDs and usernames.
`python
users = User.objects.all()
data = [
{
"id": user.id,
"username": user.username,
}
for user in users
]
`
Loading full model instances may be unnecessary for this particular use case.
Use values() to retrieve dictionaries containing selected fields:
`python
users = User.objects.values(
"id",
"username",
)
`
Or use values_list() when tuples or individual values are more appropriate:
`python
usernames = User.objects.values_list(
"username",
flat=True,
)
`
For a model instance that needs only a subset of fields, only() and defer() can also help:
`python
posts = Post.objects.only(
"id",
"title",
"created_at",
)
`
However, accessing a deferred field later can trigger another query. If your serializer eventually needs the omitted fields, this optimization may make performance worse instead of better.
Best practice: Select fields based on actual usage, especially for large text fields, JSON columns, and high-volume API responses. Measure the impact rather than applying only() everywhere.
4. Move calculations into the database with
annotate() and aggregate()
Suppose you need to display the number of comments for every post.
One approach is to retrieve all posts and count their comments individually:
`python
for post in Post.objects.all():
print(post.comments.count())
`
Depending on how the queryset is evaluated and whether related data is cached, this can result in a separate count query for every post.
Instead, let the database calculate the counts:
`python
from django.db.models import Count
posts = Post.objects.annotate(
comment_count=Count("comments")
)
for post in posts:
print(post.comment_count)
`
Use annotate() when each result needs an additional calculated value.
For a total across a queryset, use aggregate():
`python
from django.db.models import Count
result = Post.objects.aggregate(
total_posts=Count("id")
)
print(result["total_posts"])
`
This avoids loading every model instance into Python just to calculate a total.
For more complex reporting, Django also supports expressions such as Sum, Avg, F, Case, When, and database functions. These let you push suitable calculations closer to the data.
Keep in mind that joins across multiple related collections can produce duplicated rows and incorrect aggregate counts. Inspect the generated SQL and use options such as distinct=True where the relationship and intended calculation require them.
5. Use
Exists() when you only need to check whether related data exists
Suppose your application displays whether a user has liked each post.
You could load the likes or count them for every post. But if you only need a Boolean answer, retrieving or counting all matching rows is unnecessary.
Use Exists() with OuterRef():
`python
from django.db.models import Exists, OuterRef
user_likes = Like.objects.filter(
post_id=OuterRef("pk"),
user_id=request.user.pk,
)
posts = Post.objects.annotate(
liked_by_user=Exists(user_likes)
)
`
Each post now has a liked_by_user annotation that indicates whether a matching like exists.
This is especially useful for:
- Checking whether a user has bookmarked an article.
- Identifying products a customer has purchased.
- Checking whether an order has a related payment.
- Finding records that have or have not received a particular event.
For example, finding posts without approved comments:
`python
approved_comments = Comment.objects.filter(
post_id=OuterRef("pk"),
is_approved=True,
)
posts_without_approved_comments = (
Post.objects
.filter(~Exists(approved_comments))
)
`
The database can answer this existence question without your application loading every matching related object.
6. Update multiple records with
update() and bulk_update()
Suppose you need to mark thousands of expired sessions or records as inactive.
Updating each object individually creates unnecessary database round trips:
`python
for item in InventoryItem.objects.filter(
is_discontinued=True
):
item.is_active = False
item.save()
`
If you only need to change a field for all matching records, use update():
`python
InventoryItem.objects.filter(
is_discontinued=True
).update(
is_active=False
)
`
For a large number of model instances with different new values, consider bulk_update():
`python
items = list(
InventoryItem.objects.filter(
needs_repricing=True
)
)
for item in items:
item.price = calculate_new_price(item)
item.needs_repricing = False
InventoryItem.objects.bulk_update(
items,
["price", "needs_repricing"],
batch_size=500,
)
`
Choose a suitable batch size for your database and workload.
There is an important trade-off: update() and bulk_update() do not call each model instance's save() method or emit the usual pre_save and post_save signals. They can therefore bypass application behavior implemented in those places.
If your business logic depends on model overrides, signals, validation, or side effects, account for those requirements before switching to bulk operations.
7. Use
F() expressions to avoid unnecessary read-modify-write operations
Imagine a product's stock needs to decrease after a successful order.
A naive implementation might read the stock into Python, subtract one, and save the object:
`python
product = Product.objects.get(pk=product_id)
product.stock -= 1
product.save(update_fields=["stock"])
`
Under concurrent requests, two operations can read the same stock value and overwrite each other's updates.
A database-side expression can perform the arithmetic directly:
`python
from django.db.models import F
Product.objects.filter(
pk=product_id,
stock__gt=0,
).update(
stock=F("stock") - 1
)
`
The database evaluates the expression against the current stored value, avoiding the specific lost-update pattern caused by reading and then writing an old value.
However, update() returns the number of rows matched for updating. Check that result if the application needs to know whether stock was available:
`python
updated = Product.objects.filter(
pk=product_id,
stock__gt=0,
).update(
stock=F("stock") - 1
)
if updated == 0:
# Product does not exist or no stock is available.
pass
`
For more complex inventory workflows, combine appropriate database constraints, transactions, and locking where necessary. A stock decrement alone does not implement a complete order or payment workflow.
8. Add database indexes based on real query patterns
Consider an endpoint that retrieves published posts by publication date:
`python
Post.objects.filter(
status="published"
).order_by("-published_at")
`
As the table grows, the database may spend more time finding matching rows and sorting them.
A suitable index can help:
`python
class Post(models.Model):
# Other fields omitted.
class Meta:
indexes = [
models.Index(
fields=["status", "-published_at"],
name="post_status_pub_idx",
),
]
`
The exact index that helps depends on your database, table distribution, filtering conditions, ordering, and query plan.
For example, a query filtering by a single unique identifier may already use an index provided by a primary-key or unique constraint. Adding another index without a reason may provide no benefit.
Indexes also have costs:
- They consume disk space.
- They add overhead to inserts, updates, and deletes.
- They increase database maintenance work.
- They may not help when the query returns a large proportion of the table.
Use QuerySet.explain() to inspect the database's execution plan:
`python
queryset = Post.objects.filter(
status="published"
).order_by("-published_at")
print(queryset.explain())
`
The output can reveal scans, joins, estimated row counts, and other details, depending on the database backend.
Use the database's appropriate analysis tools to verify whether an index actually improves the workload. Avoid treating an index as a universal fix for every slow query.
9. Avoid loading entire querysets when processing large datasets
Django querysets are lazy: creating a queryset does not normally execute the SQL immediately. Evaluation happens when you iterate over it, convert it to a list, or perform other operations that require its results.
This is convenient for ordinary application queries, but loading a huge queryset into memory can become a problem.
For batch processing, use iterator() when you do not need the normal queryset result cache:
`python
for user in User.objects.iterator(
chunk_size=1000
):
process_user(user)
`
This can reduce memory overhead when processing a large number of records.
For a simple export or reporting operation, values_list() can further reduce the amount of model data created:
`python
for user_id, email in User.objects.values_list(
"id",
"email",
).iterator(chunk_size=1000):
export_user(user_id, email)
`
There are trade-offs. Iterator behavior and database cursor handling depend on the backend and configuration. Also, calling iterator() on a queryset that has already been evaluated can execute the query again.
When combining iteration with prefetch_related(), specify an appropriate chunk_size and verify the behavior against your Django version.
For large updates or migrations, consider bounded batches rather than fetching millions of records in a single operation.
10. Use
exists() and count() only when they match your actual requirement
A common pattern is checking whether a queryset contains records:
`python
if Post.objects.filter(
status="published"
).exists():
show_published_section()
`
exists() asks the database whether a match is present without retrieving the full result set.
Likewise, use count() when you need a count:
`python
published_count = Post.objects.filter(
status="published"
).count()
`
But optimization depends on what happens next.
Consider this example:
`python
posts = Post.objects.filter(
status="published"
)
if posts.exists():
for post in posts:
publish_preview(post)
`
If the queryset has not been evaluated, this may perform one query for the existence check and another to retrieve the posts.
If you will immediately consume the full queryset anyway, evaluating it once and reusing the result may be more efficient for small result sets:
`python
posts = list(
Post.objects.filter(status="published")
)
if posts:
for post in posts:
publish_preview(post)
`
The second approach loads all matching objects into memory, so it is not automatically better for large querysets.
The general principle is simple: choose the database operation based on what your code needs next, not because one method is always faster.
A Practical Workflow for Optimizing Django ORM Performance
Knowing the ORM methods is only half the job. Production optimization requires evidence.
Step 1: Identify the slow endpoint
Measure response time and determine whether the delay is in database access, Python processing, serialization, external API calls, or another part of the request.
A slow endpoint does not necessarily have a slow SQL query.
Step 2: Inspect query count and execution time
During development, Django Debug Toolbar can help you inspect SQL queries and identify repeated database access.
For focused tests, Django provides query-count assertions:
`python
from django.test import TestCase
class PostAPITests(TestCase):
def test_post_list_query_count(self):
# Create representative users, posts, and comments.
with self.assertNumQueries(2):
response = self.client.get("/api/posts/")
self.assertEqual(response.status_code, 200)
`
The example assumes the endpoint is designed to execute exactly two queries in the test setup. Replace the URL and expected count with the actual behavior of your application.
Query-count tests are useful for catching regressions, but they do not measure query execution time or prove that the SQL plan is efficient.
Step 3: Inspect the generated SQL and query plan
Use str(queryset.query) to inspect generated SQL during development, and use queryset.explain() to inspect the database execution plan.
For supported database backends, database-specific tools can provide deeper execution statistics. Use them carefully, especially when running commands that execute the query or collect actual runtime statistics.
Step 4: Optimize one bottleneck at a time
Start with the simplest measurable improvement:
1. Remove N+1 queries.
2. Retrieve only the required fields.
3. Push suitable calculations into SQL.
4. Add or adjust indexes based on query plans.
5. Reduce unnecessary object creation and memory usage.
6. Consider caching only after understanding the underlying bottleneck.
Step 5: Benchmark the result
Compare the same representative workload before and after the change.
Track:
- Number of SQL queries per request.
- Total database execution time.
- Endpoint p50 and p95 latency.
- Database CPU and connection usage.
- Application memory consumption.
Do not claim a performance improvement based only on fewer queries. One poorly indexed query can be slower than several efficient queries.
The objective is not to minimize query count at all costs. It is to reduce total work while preserving correctness, maintainability, and acceptable resource usage.
Frequently Asked Questions
1. What is Django ORM optimization?
Django ORM optimization means improving how your application retrieves, filters, joins, updates, and processes database records. Techniques include select_related(), prefetch_related(), database-side expressions, suitable indexes, and query-plan analysis.
2. What is the difference between
select_related() and prefetch_related()?
select_related() uses SQL joins for foreign-key and one-to-one relationships. prefetch_related() retrieves related collections separately and associates the results in Python, making it suitable for reverse foreign-key and many-to-many relationships.
3. Does fewer database queries always mean better performance?
No. Query count is only one metric. A single query with an inefficient execution plan or a huge result set can be slower than several efficient queries. Measure total query time, response latency, and resource usage.
4. When should I use
values() instead of normal model querysets?
Use values() when you need dictionaries of selected fields rather than full model instances. Use values_list() when tuples or individual field values are sufficient. Normal model instances remain appropriate when your application needs model behavior or related-object access.
5. How can I find slow queries in Django?
Use Django Debug Toolbar during development, inspect database queries in your application monitoring tools, and examine query plans with QuerySet.explain(). In production, use appropriate database monitoring and carefully configured query logging.
6. Are Django ORM indexes created automatically?
Django creates indexes for certain fields and constraints according to their definitions and database behavior, but it does not automatically create every index your workload might need. Inspect your actual query patterns and execution plans before adding indexes.
7. Is
bulk_update() always better than calling save()?
No. bulk_update() can reduce database round trips when updating many objects, but it does not call each object's save() method or emit the usual save signals. Use it only when its behavior matches your application's requirements.
8. Should I optimize the ORM before implementing caching?
Usually, you should first identify and address obvious inefficiencies such as N+1 queries, unnecessary data retrieval, and poor query plans. Caching can then help with expensive repeated work, but it introduces invalidation and data-consistency considerations.
9. How do I know whether a Django ORM optimization worked?
Compare the same workload before and after the change. Check query count, total database time, p50 and p95 response latency, and resource consumption. Use automated tests to catch regressions and production monitoring to validate real-world behavior.
Sources and Further Reading
select_related(), prefetch_related(), values(), iterator(), exists(), update(), and other queryset methods.
Django: Query Expressions — learn about F(), OuterRef(), Exists()`, and other database-side expressions.
Django: Model Indexes — learn how to define indexes on your models.
Django: Database instrumentation — explore database instrumentation for inspecting and monitoring queries.
Django Debug Toolbar — a development tool for inspecting SQL queries and request behavior.
Conclusion
Django's ORM is capable of handling demanding production workloads, but writing concise Python code is not enough to guarantee efficient database access.
The biggest improvements often come from understanding how your code translates into SQL, recognizing repeated queries, retrieving only necessary data, and allowing the database to perform work it is designed to handle.
Start with one slow endpoint. Measure it, inspect its queries, make a targeted change, and benchmark the result.
That process will teach you more about building fast Django applications than blindly applying optimization tricks across an entire codebase.