Optimizing Django ORM Queries with select_related

python backend django performance

The Django ORM is convenient, but convenience has a cost. Without careful attention, you’ll create N+1 query problems that destroy performance.

Let’s fix that.

The N+1 Problem

# models.py
class Author(models.Model):
    name = models.CharField(max_length=100)

class Book(models.Model):
    title = models.CharField(max_length=200)
    author = models.ForeignKey(Author, on_delete=models.CASCADE)

Innocent-looking code:

books = Book.objects.all()
for book in books:
    print(f"{book.title} by {book.author.name}")

What happens:

-- 1 query to get books
SELECT * FROM book;

-- N queries to get each author (one per book!)
SELECT * FROM author WHERE id = 1;
SELECT * FROM author WHERE id = 2;
SELECT * FROM author WHERE id = 3;
-- ... N more queries

With 1000 books, that’s 1001 queries. Your page takes 10 seconds to load.

select_related performs a SQL JOIN, fetching related objects in a single query:

books = Book.objects.select_related('author').all()
for book in books:
    print(f"{book.title} by {book.author.name}")  # No additional queries!
-- Single query with JOIN
SELECT book.*, author.* 
FROM book 
INNER JOIN author ON book.author_id = author.id;

Chaining Relations

# models.py
class Publisher(models.Model):
    name = models.CharField(max_length=100)

class Author(models.Model):
    name = models.CharField(max_length=100)
    publisher = models.ForeignKey(Publisher, on_delete=models.CASCADE)

class Book(models.Model):
    title = models.CharField(max_length=200)
    author = models.ForeignKey(Author, on_delete=models.CASCADE)
# Follow the chain
books = Book.objects.select_related('author__publisher').all()

for book in books:
    # All of this is already loaded
    print(f"{book.title} by {book.author.name}")
    print(f"Published by {book.author.publisher.name}")

Multiple Relations

class Book(models.Model):
    title = models.CharField(max_length=200)
    author = models.ForeignKey(Author, on_delete=models.CASCADE)
    editor = models.ForeignKey(Editor, on_delete=models.CASCADE)
books = Book.objects.select_related('author', 'editor').all()

select_related works for ForeignKey and OneToOne. For ManyToMany and reverse relations, use prefetch_related:

class Book(models.Model):
    title = models.CharField(max_length=200)
    authors = models.ManyToManyField(Author)  # Many authors per book
# prefetch_related does separate queries, then joins in Python
books = Book.objects.prefetch_related('authors').all()

for book in books:
    for author in book.authors.all():  # Already prefetched!
        print(f"{book.title} by {author.name}")
-- Query 1: Get books
SELECT * FROM book;

-- Query 2: Get all related authors
SELECT author.*, book_authors.book_id 
FROM author 
JOIN book_authors ON author.id = book_authors.author_id 
WHERE book_authors.book_id IN (1, 2, 3, ...);

Two queries instead of N+1.

Reverse Relations

# Get all authors and their books (reverse ForeignKey)
authors = Author.objects.prefetch_related('book_set').all()

for author in authors:
    for book in author.book_set.all():  # Prefetched!
        print(book.title)

Prefetch Objects: Fine-Grained Control

For complex cases, use Prefetch objects:

from django.db.models import Prefetch

# Only prefetch published books
authors = Author.objects.prefetch_related(
    Prefetch(
        'book_set',
        queryset=Book.objects.filter(published=True).order_by('-pub_date')
    )
)

# Use a custom attribute name
authors = Author.objects.prefetch_related(
    Prefetch(
        'book_set',
        queryset=Book.objects.filter(published=True),
        to_attr='published_books'  # Access as author.published_books
    )
)

for author in authors:
    for book in author.published_books:  # List, not QuerySet
        print(book.title)

Combining Both

books = Book.objects.select_related(
    'author',
    'publisher'
).prefetch_related(
    'tags',
    'reviews'
).all()

Use select_related for ForeignKey/OneToOne (JOIN). Use prefetch_related for ManyToMany/reverse (separate queries).

Debugging: Django Debug Toolbar

Install django-debug-toolbar to see all queries:

# settings.py
INSTALLED_APPS = [..., 'debug_toolbar']
MIDDLEWARE = ['debug_toolbar.middleware.DebugToolbarMiddleware', ...]
INTERNAL_IPS = ['127.0.0.1']

The SQL panel shows query count and time. Watch for duplicate queries.

Common Patterns

In Views

def book_list(request):
    books = Book.objects.select_related('author').all()
    return render(request, 'books/list.html', {'books': books})

In Serializers (DRF)

class BookViewSet(viewsets.ModelViewSet):
    serializer_class = BookSerializer
    
    def get_queryset(self):
        return Book.objects.select_related('author').prefetch_related('tags')

In Admin

@admin.register(Book)
class BookAdmin(admin.ModelAdmin):
    list_display = ['title', 'author_name']
    list_select_related = ['author']
    
    def author_name(self, obj):
        return obj.author.name

When Not to Optimize

Don’t blindly add select_related everywhere:

Profile first. The Debug Toolbar shows where the problems are.

Final Thoughts

N+1 queries are the most common Django performance issue. The fix is simple: use select_related for forward relations, prefetch_related for reverse and M2M.

Make it a habit to think about queries when writing views. Your users (and your database) will thank you.


Every query counts.

All posts