
When developing web applications with Django, your interaction with the database impacts overall application performance. Django's Object-Relational Mapper (ORM) is a powerful ally that offers an intuitive way to work with your data through abstractions called QuerySets. These are your primary tools for fetching, filtering, creating, and managing data.
In this article, we'll explore fundamental — yet highly effective — techniques to optimize your Django QuerySets. Our goal is to learn how to write database queries that are both functional and efficient. This ensures your applications remain fast, responsive, and scalable.
First, let's dig deeper into why database performance shouldn't be viewed as just a technical concern, but rather a cornerstone of a successful web application.
The Importance of Database Performance in Web Applications
A database is often the workhorse of a web application. It serves and stores the data that brings your application to life. Its performance is not an isolated metric. It has far-reaching consequences for both the end-user experience and the operational health of your servers.
So, let’s briefly discuss how performance impacts user experience and server resources in an application.
Impact on User Experience
Slow database queries are a primary culprit behind high page load times. Users’ patience is a finite resource, and when an application feels sluggish, they are more likely to leave. Over time, poor performance can lead to lower engagement and create a negative perception of your brand, eroding user trust and portraying your application as unreliable. Optimizing database performance, therefore, is directly tied to user satisfaction and retention.
Impact on Server Resources
Inefficient queries also place a strain on your server infrastructure. By forcing the database to perform complex joins or scan large tables, they consume excessive hardware resources. This pressure also limits your application's ability to scale. In extreme cases, an overloaded database can become unresponsive, leading to cascading failures that risk an application outage. Writing efficient QuerySets ensures your application makes judicious use of server resources. This leads to a stable, scalable, and cost-effective system.
Read our guide Monitor the Performance of Your Python Django App with AppSignal for more on this topic.
Understanding Django QuerySets
Before going through specific optimization techniques, it is important to have an overview of what QuerySets are, their core characteristics, and why they are a cornerstone of Django's database interaction model.
What is a QuerySet?
A Django QuerySet is a list of objects from your database. It is an abstraction layer that represents a collection of database queries. It allows you to retrieve, filter, order, and annotate data from your database using Python, rather than writing raw SQL. When you interact with your Django models, you are working with QuerySets.
Think of a QuerySet as a "recipe" for fetching data. You can define this recipe step-by-step, and Django will only execute when it absolutely has to.
Key Characteristic of QuerySets: Laziness
One of the most important characteristics of QuerySets is their "laziness." This means that the act of creating or modifying a QuerySet does not immediately result in any database activity. For example, consider the following:
from myapp.models import User # No database query has been executed yet active_users = User.objects.filter(is_active=True) recent_signups = active_users.filter(date_joined__year=2025) ordered_users = recent_signups.order_by('-date_joined')
In this code, even though you have defined active_users, recent_signups, and ordered_users, Django has not sent a SQL query to the database. Instead, Django simply builds an internal representation of the query.
The database query is executed when the QuerySet is evaluated. Evaluation typically occurs when you:
- Iterate over the QuerySet: This is perhaps the most common scenario for evaluation. When you start a loop (like a
forloop) over a QuerySet, Python needs the actual items to iterate through. At this point, Django executes the database query to fetch the rows that match your QuerySet's criteria. The database returns the data, and Django begins to instantiate model objects one by one (or in internal chunks for efficiency) as your loop progresses. The key here is that the loop's body needs the data to operate on each item. - Slice it with a
stepparameter: Basic slicing (likeordered_users[:5]to get the first five users) often returns another unevaluated QuerySet. Instead, if you use astepin the slice (e.g.,[::2]to get every second user), you force immediate evaluation. To determine which items to return when a step is involved, Django typically needs to retrieve the entire result set from the database first. Once all potential items are fetched into memory, Python can apply the stepping logic to select the appropriate items. This is different from simple limit/offset slicing, which the database can often handle directly. - Pickle or cache it: Pickling converts a Python object into a byte stream, often for storage or transmission. If you attempt to pickle a QuerySet, you generally want to pickle its results, not just the abstract query definition. So, to serialize the data, you (or the system) must first evaluate the QuerySet to fetch that data from the database. Similarly, when you cache a QuerySet, you usually aim to store its results to avoid future database hits. This process needs an initial database query to retrieve the results that you (or the system) will then place into the cache.
- Call
repr()orlen()on it:- Calling
repr()on a QuerySet will trigger its evaluation. To provide a string representation, Django usually needs to fetch at least a subset of the results to display (e.g.,<QuerySet [<User: Alice>, <User: Bob>, ...]>). - Calling
len()on a QuerySet also causes evaluation. To determine the number of items in the QuerySet, Django will execute the query, retrieve all the matching objects into memory, and then count how many there are. This is why it's generally less efficient than using thequeryset.count()method if all you need is the count. In fact, it performs a more optimizedSELECT COUNT(*)at the database level.
- Calling
- Explicitly convert it to a list: When you explicitly try to convert a QuerySet into a Python
listusing thelist()constructor, you are signaling that you need all the results of that query available in memory as a standard Python list. This forces Django to execute the database query, retrieve all matching rows, instantiate them as model instances (or dictionaries/tuples), and populate the list with these items. - Call methods that return a single value or object:
Many QuerySet methods are designed to return a specific piece of information or a single object, rather than a collection.
count(): To return the total number of matching records, the database must be queried to perform the count.exists(): To determine if at least one matching record exists, a query must be sent to the database (though it's highly optimized to stop at the first find).first(),last(),earliest(),latest(): These methods are designed to retrieve a single specific object from the ordered QuerySet. This requires a database query to find and fetch that particular record.get(): This method is used to retrieve a single, unique object matching the given lookup parameters. It inherently requires a database query. If no object or multiple objects are found, it raises an exception, which also implies the query was executed.- Methods like
aggregate(),earliest(),latest()also trigger immediate database queries because they compute a result based on the entire QuerySet.
This lazy evaluation is beneficial on the side of database performance because:
- It allows you to chain multiple filters and operations efficiently. Django can optimize the entire chain into a single, more efficient SQL query.
- You can create and pass QuerySets around in your code without incurring unnecessary database hits until the data is actually needed.
Why Use Django QuerySets?
QuerySets are a fundamental part of Django's ORM for several reasons:
- Pythonic interface: They provide a clean, intuitive, and Pythonic way to interact with your database. This makes your data access code readable, maintainable, and easier to understand.
- Abstraction over SQL: QuerySets abstract away the complexities and variations of SQL syntax across different database backends. Django handles the translation from your Python QuerySet methods to the appropriate SQL for your configured database. This also provides a significant degree of database portability.
- Security: By default, QuerySets protect against SQL injection attacks because parameters are escaped correctly by the database driver. Writing raw SQL queries manually can be error-prone and open up security vulnerabilities if not handled with extreme care.
- Developer productivity: They significantly speed up development by reducing the amount of boilerplate code needed for common database operations.
- Built-in features for optimization: As you are about to see, Django's ORM and QuerySets come with several built-in features and methods designed to help you write more performant database queries.
Implementing Basic QuerySet Performance Techniques
Let's implement some basic, yet impactful, performance techniques for Django QuerySets.
Requirements, Dependencies, and Settings
To reproduce this tutorial, you need to have Python 3.6 or newer installed.
Suppose you call the main folder of your project django_query_sets/. At the end of this step, the folder will have the following structure:
django_query_sets/ └── venv/
Where venv/ contains the virtual environment. You can create the venv/ virtual environment directory like so:
python -m venv venv
To activate it, run this on Windows:
venv\Scripts\activate
On macOS and Linux, execute:
source venv/bin/activate
In the activated virtual environment, install the dependencies with:
pip install django
This will install the latest version of Django.
Create your Django project as query_sets_project/ in the django_query_sets/ folder type:
django-admin startproject query_sets_project
Your folder now has the following structure:
django_query_sets/ ├── query_sets_project │ ├── query_sets_project │ │ ├── __init__.py │ │ ├── settings.py │ │ ├── urls.py │ │ ├── asgi.py │ │ └── wsgi.py │ └── manage.py │ └── venv/
The query_sets_project/ folder contains the main project files. In this tutorial, you will create an application that you can call catalog. To do so, from django_query_sets/ navigate to query_sets_project/:
cd query_sets_project
Now, you can instantiate the catalog/ folder:
python manage.py startapp catalog
The whole project now has the following structure:
django_query_sets/ ├── query_sets_project/ │ ├── manage.py │ ├── query_sets_project/ │ │ ├── __init__.py │ │ ├── asgi.py │ │ ├── settings.py │ │ ├── urls.py │ │ └── wsgi.py │ └── catalog/ │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── models.py │ ├── tests.py │ ├── views.py │ └── migrations/ │ └── __init__.py └── venv/
Go to the django_query_sets/query_sets_project/query_sets_project/settings.py file. Inside it, there is a list of installed apps which appears as follows:
INSTALLED_APPS = [ "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", ]
You need to add your catalog application to the list:
INSTALLED_APPS = [ "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", 'catalog.apps.CatalogConfig', # Add catalog app to the list ]
Perfect! You are now ready to start writing Django code and using QuerySets!
Setting up a Sample Model
Let's define a couple of Django models in catalog/models.py.
Create Author and Book models, establishing a one-to-many relationship:
from django.db import models from django.utils import timezone class Author(models.Model): name = models.CharField(max_length=100, unique=True) bio = models.TextField(blank=True, null=True) # Author biography def __str__(self): return self.name class Book(models.Model): title = models.CharField(max_length=200) # related_name='books' allows us to do author_instance.books.all() author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name='books') publication_date = models.DateField() pages = models.IntegerField() price = models.DecimalField(max_digits=6, decimal_places=2) # ISBN is unique, but can be nullable if not always available isbn = models.CharField(max_length=13, unique=True, null=True, blank=True) class Meta: ordering = ['title'] # Default ordering for Book queries def __str__(self): return self.title
After defining or modifying your models, you must create and apply database migrations (via the CLI):
python manage.py makemigrations catalog python manage.py migrate
Below is the result:

This updates your database schema to reflect your model definitions.
Now, populate the database with some sample data. You can do this via the Django admin interface (after registering your models in admin.py) or programmatically, using the Django shell (python manage.py shell).
In this case, we will use the Python shell. Inside the django_query_sets/query_sets_project/ folder, type:
python manage.py shell
Paste the following code into the CLI:
from catalog.models import Author, Book from django.utils import timezone import datetime # Create an author author1 = Author.objects.create(name="George Orwell") author2 = Author.objects.create(name="J.R.R. Tolkien") # Create some books Book.objects.create( title="1984", author=author1, publication_date=datetime.date(1949, 6, 8), pages=328, price=15.99, isbn="9780451524935" ) Book.objects.create( title="Animal Farm", author=author1, publication_date=datetime.date(1945, 8, 17), pages=112, price=12.50, isbn="9780451526342" ) Book.objects.create( title="The Hobbit", author=author2, publication_date=datetime.date(1937, 9, 21), pages=310, price=14.00, isbn="9780547928227" ) Book.objects.create( title="The Lord of the Rings", author=author2, publication_date=datetime.date(1954, 7, 29), pages=1178, price=25.00, isbn="9780618640157" ) print("Sample data created successfully!")
When done, type exit().
The data you have created will now be persisted in your database. You are ready to query it using QuerySets!
Fetching Specific Fields with values() and values_list()
By default, when you retrieve objects from a database, Django fetches all fields for each object. If you only need a subset of these fields, fetching everything is wasteful. This is where values() and values_list() come in handy:
values(*fields): Returns a QuerySet that returns dictionaries, rather than model instances, when used as an iterable. Each dictionary represents an object, with the keys corresponding to the field names you specified.values_list(*fields, flat=False): Similar tovalues(), but it returns tuples instead of dictionaries. If you only specify one field, you can useflat=Trueto get a flat list of single values.
This is more performant because fetching only specific fields reduces the amount of data transferred from the database to your application and decreases the memory required to hold the results. It also avoids the overhead of creating full model instances if you do not need them.
For example, say you only need the titles and publication dates of all books. In catalog/views.py, write the following:
from django.shortcuts import render from django.http import HttpResponse from .models import Book def book_titles_and_dates_view(request): # Using values() book_data_dicts = Book.objects.values('title', 'publication_date') output_lines = ["<h3>Book Titles and Publication Dates (using values()):</h3>"] for book_dict in book_data_dicts: # Accessing data by dictionary keys output_lines.append(f"Title: {book_dict['title']}, Published: {book_dict['publication_date']}") return HttpResponse("<br>".join(output_lines)) def book_titles_only_view(request): # Using values_list() book_titles = Book.objects.values_list('title', flat=True) output_lines = ["<h3>Book Titles Only (using values_list(flat=True)):</h3>"] # Iterating over a flat list of titles for title in book_titles: output_lines.append(title) # Print query to console print("--- Book Titles (from values_list with flat=True) ---") for title in book_titles: print(title) print("----------------------------------------------------") print(str(book_titles.query)) return HttpResponse("<br>".join(output_lines))
To see these views in action, you need to map them to URLs. Create a catalog/urls.py file and write the following:
from django.urls import path from . import views app_name = 'catalog' # Optional but good practice for namespacing urlpatterns = [ path('titles-dates/', views.book_titles_and_dates_view, name='book_titles_dates'), path('titles-only/', views.book_titles_only_view, name='book_titles_only'), ]
Then, modify query_sets_project/query_sets_project/urls.py to include the catalog/urls.py file. The code you will find in query_sets_project/query_sets_project/urls.py is:
from django.contrib import admin from django.urls import path urlpatterns = [ path("admin/", admin.site.urls), ]
Change it as follows:
from django.contrib import admin from django.urls import path, include urlpatterns = [ path('admin/', admin.site.urls), path('catalog/', include('catalog.urls')), ]
To see the results, navigate to your project's root directory (django_query_sets/query_sets_project/) in your terminal and run:
python manage.py runserver
To see the results, go to the dedicated URLs:
When the titles-only views run, you can see the resulting query via the CLI:

Now, to see the difference in performance with the "standard method", you can add this method in catalog/views.py:
def all_book_details_view(request): # This fetches ALL fields for every book from the database. all_books = Book.objects.all() # Print SQL query print(str(all_books.query)) output_lines = ["<h3>All Book Details (Default Method):</h3>"] for book in all_books: output_lines.append(book.title) return HttpResponse("<br>".join(output_lines))
The database is doing the work of retrieving every column, and Django is building a full model instance for each row, only for us to use a single field: title. This is the inefficiency eliminated by values() and values_list().
In catalog/urls.py, add a dedicated URL:
urlpatterns = [ # Existing code not shown for brevity path('all-details/', views.all_book_details_view, name='all_book_details'), # Add this line ]
Rerun the server and go to http://127.0.0.1:8000/catalog/all-details/. The result you see in the UI is the same as at http://127.0.0.1:8000/catalog/titles-only/. What changes is the query under the hood. You can see it printed in the command line:

This shows that you are obtaining the same result (listed titles), but with a longer and inefficient query.
Efficient Counting with count()
Often, you just need to know how many objects match a particular query, not the objects themselves. A naive approach might be to fetch all objects and then use len() on the resulting list:
all_books = Book.objects.all() number_of_books = len(all_books) # This evaluates the QuerySet and loads all book objects
This is inefficient because it loads all the book objects into memory just to count them. Django QuerySets provides a much better way: the count() method.
It performs a SELECT COUNT(*) (or a similar count-specific query) at the database level. This is significantly faster and uses far less memory than retrieving all objects.
Let's add a view to demonstrate this. Add the following as a new function in catalog/views.py (do not cancel the others):
from .models import Author # Add this import on top of the file from django.db import connection # Add this import on top of the file def book_count_view(request): # Total count query total_books = Book.objects.count() print("\n--- Actual query for total book count ---") print(connection.queries[0]['sql']) print("-----------------------------------------") # Filtered count query connection.queries.clear() orwell_books_count = Book.objects.filter(author__name="George Orwell").count() print("\n--- Actual query for filtered book count ---") print(connection.queries[0]['sql']) print("--------------------------------------------") output_lines = [ "<h3>Book Counts (using count()):</h3>", f"Total books in the library: {total_books}", f"Number of books by George Orwell: {orwell_books_count}" ] return HttpResponse("<br>".join(output_lines))
Now add a dedicated URL in catalog/urls.py:
from django.urls import path from . import views app_name = 'catalog' urlpatterns = [ # Existing code not shown for brevity path('book-counts/', views.book_count_view, name='book_counts'), # Add this line ]
The result is shown at http://127.0.0.1:8000/catalog/book-counts/, after re-running the server:

The console shows the underlying query:

As before, you can create a function that performs the same query (but inefficiently) in views.py:
def book_count_slow_view(request): # Fetch all book objects from the database all_books = Book.objects.all() print("\n--- Query for book_count_slow_view ---") print(str(all_books.query)) print("--------------------------------------") # Use len() for loading all objects into memory number_of_books = len(all_books) output_lines = [ "<h3>Book Count (Slow Method):</h3>", f"Total books in the library: {number_of_books}" ] return HttpResponse("<br>".join(output_lines))
Add the URL to urls.py:
urlpatterns = [ # Existing code not shown for brevity path('book-counts-slow/', views.book_count_slow_view, name='book_counts_slow'), # Add this line ]
Rerun the server and go to http://127.0.0.1:8000/catalog/book-counts-slow/. You will see the same result in the UI as the one in http://127.0.0.1:8000/catalog/book-counts/. Again, what changes is the query under the hood. You can see it printed in the command line:

The image shows how inefficient this query is, as it retrieves all the data from the database.
Efficient Existence Checks with exists()
Sometimes, you only need to know whether at least one object matches your query. For example: "Are there any books by J.R.R. Tolkien in the database?"
To do so efficiently, create a view by adding a new function in catalog/views.py that uses the method exists():
def check_books_exist_view(request): # Use the efficient method exists() connection.queries.clear() tolkien_books_exist = Book.objects.filter(author__name="J.R.R. Tolkien").exists() print("\n--- Actual query for exists() check ---") print(connection.queries[0]['sql']) print("---------------------------------------") output_lines = ["<h3>Book Existence Checks (using exists()):</h3>"] if tolkien_books_exist: output_lines.append("Yes, there are books by J.R.R. Tolkien in the database.") else: output_lines.append("No books by J.R.R. Tolkien found.") return HttpResponse("<br>".join(output_lines))
As before, add a new URL in catalog/urls.py:
urlpatterns = [ # Existing code not shown for brevity path('check-existence/', views.check_books_exist_view, name='check_book_existence'), # Add this line ]
After re-running the server, the result is visible at http://127.0.0.1:8000/catalog/check-existence/ :

The console shows the underlying query:

To obtain the same result, but inefficiently, you could create such a function in views.py:
def check_books_exist_slow_view(request): output_lines = ["<h3>Book Existence Checks (Slow Methods):</h3>"] # Inefficient method 1: Using count() > 0 connection.queries.clear() # This asks the database to find and count ALL matching books. if Book.objects.filter(author__name="J.R.R. Tolkien").count() > 0: output_lines.append("Using count(): Yes, books by J.R.R. Tolkien exist.") print("\n--- Actual query for count() existence check ---") print(connection.queries[0]['sql']) print("------------------------------------------------") # Inefficient method 2: Evaluating the QuerySet directly connection.queries.clear() # This fetches ALL matching books into memory. if Book.objects.filter(author__name="J.R.R. Tolkien"): output_lines.append("Using boolean check: Yes, books by J.R.R. Tolkien exist.") print("\n--- Actual query for boolean existence check ---") print(connection.queries[0]['sql']) print("------------------------------------------------") return HttpResponse("<br>".join(output_lines))
Create a new URL in urls.py:
urlpatterns = [ # Existing code not shown for brevity path('check-existence-slow/', views.check_books_exist_slow_view, name='check_book_existence_slow') # Add this line ]
When checking http://127.0.0.1:8000/catalog/check-existence-slow/, you will see this result:

Again, what changes is the query:

In this case, one query counts to the end, and the other fetches too much data. Instead, when using exists(), the optimization of SQL contains the LIMIT 1 clause, which is the database's instruction to perform the absolute minimum work required.
Side-note: While Django's ORM is powerful, it's important to be aware of potential pitfalls like the N+1 query problem, which can significantly degrade performance when fetching related objects. For a detailed guide on identifying and fixing these issues with AppSignal, check out Find and Fix N+1 Queries in Django Using AppSignal.
Wrapping Up
In this article, we explored key techniques for optimizing Django QuerySets. These methods reduce data transfer, minimize memory usage, and lessen the load on your database server, leading to a faster, more responsive, and scalable web application. Mastering these is a crucial step for any Django developer aiming to build high-performance systems.
Happy coding!
Wondering what you can do next?
Finished this article? Here are a few more things you can do:
- Share this article on social media
Most popular Python articles

An Introduction to Flask-SQLAlchemy in Python
In this article, we'll introduce SQLAlchemy and Flask-SQLAlchemy, highlighting their key features.
See more
Monitor the Performance of Your Python Flask Application with AppSignal
Let's use AppSignal to monitor and improve the performance of your Flask applications.
See more
Find and Fix N+1 Queries in Django Using AppSignal
We'll track the N+1 query problem in a Django app and fix it using AppSignal.
See more

Federico Trotta
Guest author Federico is a freelance Technical Writer who specializes in writing technical articles and documenting digital products. His mission is to democratize software through technical content.
All articles by Federico TrottaBecome our next author!
AppSignal monitors your apps
AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!



