~7 min read • Updated Mar 10, 2026
1. Profile First
Before optimizing anything, measure it. Profiling helps you understand which queries are being executed and how expensive they are.
Useful tools include:
QuerySet.explain()for analyzing query execution plans- django-debug-toolbar for real-time query inspection
- Database monitoring tools (e.g., pgAdmin, MySQL Workbench)
Remember that optimization may target speed, memory usage, or both. Always re-profile after each change to ensure the improvement is meaningful.
2. Use Standard Database Optimization Techniques
2.1 Indexes
Indexes are often the most impactful optimization. After profiling, add indexes to fields frequently used in:
filter()exclude()order_by()- lookups and joins
Ways to add indexes:
db_index=Trueon fieldsMeta.indexesfor composite or advanced indexes
Be aware that maintaining indexes has a cost—more writes, more storage—so choose wisely.
2.2 Appropriate Field Types
Choosing the correct field type matters. For example, storing numbers in CharField is inefficient and slows down queries.
3. Understand QuerySets
QuerySets are lazy, meaning they don’t hit the database until absolutely necessary.
3.1 When QuerySets Are Evaluated
Evaluation happens when:
- You iterate over a QuerySet
- You convert it to a list
- You access the first/last element
- It is rendered in a template
3.2 Cached Attributes
Non-callable attributes are cached:
entry = Entry.objects.get(id=1)
entry.blog # First access → DB query
entry.blog # Second access → cached
But callable attributes (like reverse relations) are not cached:
entry.authors.all() # Query
entry.authors.all() # Query again
Templates automatically call callables, which can hide repeated queries.
3.3 Custom Properties
If you define custom properties, use @cached_property when appropriate to avoid repeated work.
4. Use the with Template Tag
To avoid repeated QuerySet evaluation in templates, store results using {% with %}.
5. Use iterator() for Large Datasets
QuerySet caching can consume large amounts of memory.
Use iterator() to stream results without caching:
for obj in MyModel.objects.all().iterator():
...
6. Use explain() for Query Analysis
QuerySet.explain() shows how the database executes a query:
- Which indexes are used
- Join strategies
- Scan types (Index Scan, Sequential Scan, etc.)
This helps identify inefficient queries or missing indexes.
7. Do Work in the Database, Not in Python
Let the database handle filtering, aggregation, and comparisons.
Examples:
- Use
filter()andexclude()instead of filtering in Python - Use
F()expressions for field-to-field comparisons - Use
annotate()for aggregation
8. Use RawSQL When Needed
If Django’s ORM cannot express the query you need, use RawSQL:
from django.db.models.expressions import RawSQL
MyModel.objects.annotate(
custom=RawSQL("SELECT ...", [])
)
9. Use Raw SQL for Maximum Control
When all else fails, write SQL manually:
from django.db import connection
with connection.cursor() as cursor:
cursor.execute("SELECT ...")
To inspect the SQL Django generates:
from django.db import connection
print(connection.queries)
Conclusion
Optimizing database access in Django requires a combination of profiling, understanding QuerySet behavior, using indexes effectively, and pushing work into the database rather than Python. By applying these techniques, you can significantly improve the performance and efficiency of your application.
1. Retrieve Individual Objects Using Unique, Indexed Columns
When using get(), always prefer columns that are:
- Indexed
- Unique
Example:
entry = Entry.objects.get(id=10)
This is much faster than:
entry = Entry.objects.get(headline="News Item Title")
And significantly faster than:
entry = Entry.objects.get(headline__startswith="News")
Because:
headlineis not indexedstartswithmay match thousands of rows- Network overhead increases if the DB is remote
2. Retrieve Everything at Once If You Know You Will Need It
Fetching data in multiple queries when one query would suffice is inefficient—especially inside loops.
3. Use select_related() and prefetch_related()
These are essential tools for reducing query counts:
- select_related() → performs SQL joins (for FK and OneToOne)
- prefetch_related() → performs two queries and merges results in Python (for M2M and reverse FK)
Use them:
- In managers
- In views
- With
prefetch_related_objects()when needed
4. Don’t Retrieve Data You Don’t Need
4.1 Use values() and values_list()
If you only need raw values, not full model instances:
Entry.objects.values("id", "headline")
4.2 Use defer() and only()
To avoid loading large or rarely used fields:
Entry.objects.only("id", "headline")
Warning: Accessing deferred fields later triggers extra queries.
5. Use contains(), count(), and exists() Correctly
5.1 contains()
Use when checking membership:
qs.contains(obj)
5.2 count()
Use when you only need the number of rows:
qs.count()
5.3 exists()
Use when checking if at least one row exists:
qs.exists()
5.4 Don’t Overuse Them
If you will need the actual data, evaluate the QuerySet once and reuse the cache.
Optimal Example:
members = group.members.all()
if display_group_members:
if members:
if current_user in members:
print("You and", len(members) - 1, "other users are members.")
else:
print("There are", len(members), "members.")
for member in members:
print(member.username)
else:
print("There are no members.")
This performs at most one query.
6. Use update() and delete() for Bulk Operations
Instead of loading objects and saving them one by one:
Entry.objects.filter(...).update(status="published")
Or:
Entry.objects.filter(...).delete()
Note: These do not call save() or delete() on individual instances, so custom logic and signals won’t run.
7. Use Foreign Key Values Directly
If you only need the foreign key ID:
entry.blog_id
Instead of:
entry.blog.id
The second version triggers an extra query.
8. Don’t Order Results If You Don’t Care
Ordering is expensive. If you don’t need it:
Entry.objects.order_by()
This removes default ordering.
9. Use Bulk Methods
To reduce the number of SQL statements:
bulk_create()bulk_update()update()delete()
Conclusion
Optimizing data retrieval in Django requires thoughtful use of QuerySet features, indexing, bulk operations, and careful avoidance of unnecessary queries. By applying these techniques, you can significantly improve performance and scalability in your Django applications.
1. Create Objects in Bulk
When creating multiple objects, bulk_create() is far more efficient than calling create() repeatedly.
Efficient version:
Entry.objects.bulk_create(
[
Entry(headline="This is a test"),
Entry(headline="This is only a test"),
]
)
Inefficient version:
Entry.objects.create(headline="This is a test")
Entry.objects.create(headline="This is only a test")
Note: bulk_create() has caveats (e.g., no signals, no auto-updated fields), so ensure it fits your use case.
2. Update Objects in Bulk
When updating multiple objects, bulk_update() reduces the number of SQL queries dramatically.
Efficient version:
entries = Entry.objects.bulk_create(
[
Entry(headline="This is a test"),
Entry(headline="This is only a test"),
]
)
entries[0].headline = "This is not a test"
entries[1].headline = "This is no longer a test"
Entry.objects.bulk_update(entries, ["headline"])
Inefficient version:
entries[0].headline = "This is not a test"
entries[0].save()
entries[1].headline = "This is no longer a test"
entries[1].save()
Note: bulk_update() also skips signals and custom save() logic.
3. Insert Many-to-Many Relationships in Bulk
3.1 Using add() with multiple objects
When adding multiple related objects, pass them all at once:
my_band.members.add(me, my_friend)
Instead of:
my_band.members.add(me)
my_band.members.add(my_friend)
3.2 Using bulk_create() for through models
When inserting multiple pairs or using a custom through model:
PizzaToppingRelationship = Pizza.toppings.through
PizzaToppingRelationship.objects.bulk_create(
[
PizzaToppingRelationship(pizza=my_pizza, topping=pepperoni),
PizzaToppingRelationship(pizza=your_pizza, topping=pepperoni),
PizzaToppingRelationship(pizza=your_pizza, topping=mushroom),
],
ignore_conflicts=True,
)
Instead of:
my_pizza.toppings.add(pepperoni)
your_pizza.toppings.add(pepperoni, mushroom)
4. Remove Many-to-Many Relationships in Bulk
4.1 Using remove() with multiple objects
my_band.members.remove(me, my_friend)
Instead of:
my_band.members.remove(me)
my_band.members.remove(my_friend)
4.2 Using delete() with Q expressions for through models
from django.db.models import Q
PizzaToppingRelationship = Pizza.toppings.through
PizzaToppingRelationship.objects.filter(
Q(pizza=my_pizza, topping=pepperoni)
| Q(pizza=your_pizza, topping=pepperoni)
| Q(pizza=your_pizza, topping=mushroom)
).delete()
Instead of:
my_pizza.toppings.remove(pepperoni)
your_pizza.toppings.remove(pepperoni, mushroom)
Conclusion
Bulk operations in Django—whether creating, updating, inserting, or removing related objects—significantly reduce the number of SQL queries and improve performance. However, because bulk methods bypass signals and custom model logic, they should be used thoughtfully and only when appropriate.
Written & researched by Dr. Shahin Siami