jh_.app

Django Query Optimization: Where to Put Your Prefetch Logic

One of Django's most powerful features is its ORM, but it's also one of the easiest places to introduce performance problems. Learn clear patterns for where to put your prefetch logic as your application grows.
Django Query Optimization

The solution is well-known: use select_related() and prefetch_related(). But as your application grows, a more subtle question emerges: where should you put that optimization logic?

Should it live in your views? In your manager? Always applied, or conditionally? Recently, while working on a feature involving complex related data, a colleague shared some clear patterns for when to use each approach. Here's what I learned.

The Challenge: Repeated Prefetch Logic

Imagine you have a Product model with a Variant relationship. In 90% of your views, you need to display each product's active variant for today. You start writing:

# In view 1
products = Product.objects.prefetch_related(
    'variants'
)
for product in products:
    variant = product.variants.filter(
        start_date__lte=timezone.localdate()
    ).first()

# In view 2
products = Product.objects.prefetch_related(
    'variants'
)
for product in products:
    variant = product.variants.filter(
        start_date__lte=timezone.localdate()
    ).first()

# In view 3 (and so on...)

This is problematic:

  • The same prefetch and filtering logic is repeated across your codebase
  • Each .filter() call triggers a new database query (N+1 problem!)
  • You can't easily change how the "active variant" is determined
  • Testing requires understanding implementation details

A first improvement might be to add a method to the Product model:

class Product(models.Model):
    # ... fields ...

    @cached_property
    def active_variant(self):
        return self.variants.filter(
            start_date__lte=timezone.localdate()
        ).order_by('-start_date').first()

This is cleaner, but it doesn't solve the N+1 problem. When you query for a set of Product objects and access active_variant on each one, you're still executing a separate database query for every single product. With 100 products, that's 100 additional queries. There's a better way.

Pattern 1: Manager Methods for Predictable Query Patterns

A colleague showed me the most valuable pattern: encapsulating complex prefetch logic in custom manager methods.

class ProductQuerySet(models.QuerySet):
    def with_active_variant(self, date=None):
        """Prefetch active variant for date."""
        if date is None:
            date = timezone.localdate()

        qs = Variant.objects.filter(
            start_date__lte=date
        ).order_by('-start_date')[:1]

        return self.prefetch_related(
            Prefetch(
                'variants',
                queryset=qs,
                to_attr='_active_variant'
            )
        )

class Product(models.Model):
    # ... fields ...
    objects = ProductQuerySet.as_manager()

Now your views become clean and consistent:

# All views use the same optimized query
products = Product.objects.with_active_variant()

When to use this pattern:

  • You have a predictable, frequently-used query pattern (needed in 90%+ of contexts)
  • The query is parametrized (requires arguments like dates, filters)
  • The optimization is complex (nested prefetches, annotations, filtering)

This approach provides several benefits:

  • Reusability: Write once, use everywhere
  • Clarity: Domain-specific names make intent explicit
  • Performance: Optimization is uniformly applied across your codebase
  • Testability: Easy to test the optimization logic in isolation

Pattern 2: Default Manager QuerySet

Sometimes a relationship is so fundamental that you always need it. For these cases, bake the optimization into your default manager:

class ProductManager(models.Manager):
    def get_queryset(self):
        return super().get_queryset().select_related(
            'category',
            'manufacturer'
        )

class Product(models.Model):
    category = models.ForeignKey(
        Category,
        on_delete=models.CASCADE
    )
    manufacturer = models.ForeignKey(
        Manufacturer,
        on_delete=models.CASCADE
    )

    objects = ProductManager()

Every query automatically includes the related data:

# Category and manufacturer are automatically loaded
products = Product.objects.all()

When to use this pattern:

  • The relationship is always needed (>95% of queries require it)
  • The optimization is simple (basic select_related, no parameters)
  • It's a fundamental relationship (like User → Profile, Order → Customer)

Consider these trade-offs:

  • Adds a small overhead to the rare queries that don't need the data
  • Can't be parameterized or conditional
  • Best for select_related(), be careful with prefetch_related() (can fetch large datasets unnecessarily)

Pattern 3: Model Property with Caching

The third pattern bridges the gap between query optimization and convenient access:

class Product(models.Model):
    # ... fields ...

    @cached_property
    def active_variant(self):
        """Get active variant for today."""
        # Try prefetched data
        if hasattr(self, '_active_variant'):
            variants = self._active_variant
            return variants[0] if variants else None

        # Fallback to query
        return self.variants.filter(
            start_date__lte=timezone.localdate()
        ).order_by('-start_date').first()

This gives you the best of both worlds:

# Efficient with manager method
products = Product.objects.with_active_variant()
for product in products:
    variant = product.active_variant

# Single object - still works
product = Product.objects.get(id=1)
variant = product.active_variant

When to use this pattern:

  • You want convenient per-instance access to related data
  • You need flexibility (works with or without prefetch)
  • It involves business logic beyond pure query optimization

This approach provides several benefits:

  • Clean, readable code in templates and views
  • Works in all contexts (bulk queries and single objects)
  • Fails gracefully without prefetch (trades performance, not correctness)

Notice the recurring pattern of filter().order_by()[:1] in our Variant query? This too can be abstracted into a custom manager method on the Variant model itself. You can create a VariantQuerySet.active_on(date) method that encapsulates this logic. This way, your optimization becomes composable—each model manages its own query patterns, and you combine them at the higher level. This keeps your code DRY and makes testing individual query behaviors much simpler.

Conclusion

Query optimization in Django isn't just about knowing select_related() and prefetch_related()—it's about architectural decisions that affect your entire codebase.

The patterns outlined here provide a framework for making those decisions:

  1. Manager methods for predictable, complex query patterns
  2. Default querysets for fundamental, always-needed relationships
  3. Model properties for convenient, flexible access

Each pattern has its place. Used together, they create a clean separation of concerns: your manager handles optimization, your model provides convenient access, and your views stay focused on business logic.

The upfront investment in setting up these patterns pays dividends. Your code becomes more maintainable, your performance optimizations are uniformly applied, and you prevent entire classes of N+1 query bugs before they happen.

Most importantly, these patterns scale. As your application grows and your relationships become more complex, the abstraction layers you've built will carry you through without needing constant refactoring.