python

Improve Query Performance Using Python Django QuerySets

Federico Trotta

Federico Trotta on

Improve Query Performance Using Python Django QuerySets

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:

Python
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 for loop) 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 step parameter: Basic slicing (like ordered_users[:5] to get the first five users) often returns another unevaluated QuerySet. Instead, if you use a step in 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() or len() 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 the queryset.count() method if all you need is the count. In fact, it performs a more optimized SELECT COUNT(*) at the database level.
  • Explicitly convert it to a list: When you explicitly try to convert a QuerySet into a Python list using the list() 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:

  1. It allows you to chain multiple filters and operations efficiently. Django can optimize the entire chain into a single, more efficient SQL query.
  2. 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:

plaintext
django_query_sets/ └── venv/

Where venv/ contains the virtual environment. You can create the venv/ virtual environment directory like so:

Shell
python -m venv venv

To activate it, run this on Windows:

Shell
venv\Scripts\activate

On macOS and Linux, execute:

Shell
source venv/bin/activate

In the activated virtual environment, install the dependencies with:

Shell
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:

Shell
django-admin startproject query_sets_project

Your folder now has the following structure:

plaintext
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/:

Shell
cd query_sets_project

Now, you can instantiate the catalog/ folder:

Shell
python manage.py startapp catalog

The whole project now has the following structure:

plaintext
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:

Python
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:

Python
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:

Python
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):

Shell
python manage.py makemigrations catalog python manage.py migrate

Below is the result:

Applying migrations to a Django project

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:

Shell
python manage.py shell

Paste the following code into the CLI:

Python
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 to values(), but it returns tuples instead of dictionaries. If you only specify one field, you can use flat=True to 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:

Python
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:

Python
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:

Python
from django.contrib import admin from django.urls import path urlpatterns = [ path("admin/", admin.site.urls), ]

Change it as follows:

Python
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:

Shell
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:

Optimized query in Django with QuerySets

Now, to see the difference in performance with the "standard method", you can add this method in catalog/views.py:

Python
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:

Python
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:

Optimized query in Django with QuerySets inefficient titles

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:

Python
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):

Python
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:

Python
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:

Counting in Django with QuerySets

The console shows the underlying query:

Counting optimized in Django with QuerySets

As before, you can create a function that performs the same query (but inefficiently) in views.py:

Python
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:

Python
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:

Counting in Django with QuerySets

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():

Python
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:

Python
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/ :

Using exists in Django with QuerySets

The console shows the underlying query:

Using exists query in Django with QuerySets

To obtain the same result, but inefficiently, you could create such a function in views.py:

Python
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:

Python
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:

Using inefficient exists in Django with QuerySets

Again, what changes is the query:

Using inefficient exists query in Django with QuerySets

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
Federico Trotta

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 Trotta

Become our next author!

Find out more

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!

Discover AppSignal
AppSignal monitors your apps