Last modified: Oct 14, 2026
Filter QuerySets with django-filter
Every Django app eventually needs filtering. Users want to narrow a list by category, date, price, or status. Writing that logic by hand in every view gets old fast.
django-filter solves this with a clean declarative API. You define a FilterSet, and the package handles query building and validation for you.
This guide shows you how to filter QuerySets with django-filter. You will start with the basics and move through custom fields, lookups, and DRF integration.
The Problem with Manual Filtering
Consider a simple product list view. Filtering by category might look like this at first.
def product_list(request): category = request.GET.get("category") queryset = Product.objects.all() if category: queryset = queryset.filter(category=category) return render(request, "products.html", {"products": queryset}) That works for one filter. Then the product owner asks for price ranges. Then a search box. Then a date filter.
Before long the view is forty lines of conditional logic. Each new filter adds branching, validation, and edge cases.
django-filter replaces all of that with a class definition. The filtering rules live in one place, and the view stays short.
If you have not set it up yet, follow this guide on how to install django-filter in Python first. Everything below assumes the package is registered in your settings.
How a FilterSet Works
A FilterSet is a declarative class that maps URL parameters to queryset filters. It reads the incoming data, validates it, and returns a filtered result.
The class mirrors the shape of a Django ModelForm. You declare fields, and django-filter generates the form inputs and the query logic.
Two attributes matter most. The first is Meta.model, which names the model being filtered. The second is Meta.fields, which lists the exposed filter names.
Here is the smallest useful FilterSet.
# filters.py import django_filters from .models import Product class ProductFilter(django_filters.FilterSet): class Meta: model = Product fields = ["category", "in_stock"] Two lines of configuration give you two working filters. No conditional logic, no manual parsing.
Applying a FilterSet to a Queryset
You attach a FilterSet to a queryset by passing both the incoming data and the base queryset.
# views.py from django.shortcuts import render from .models import Product from .filters import ProductFilter def product_list(request): product_filter = ProductFilter(request.GET, queryset=Product.objects.all()) return render(request, "products.html", {"filter": product_filter}) The request.GET dictionary holds the query parameters from the URL. django-filter parses them and validates each value against the field type.
The filtered result is available on the filter object itself. Access it with the .qs attribute whenever you need the queryset separately.
def product_list(request): product_filter = ProductFilter(request.GET, queryset=Product.objects.all()) context = { "filter": product_filter, "products": product_filter.qs, } return render(request, "products.html", context) Separating the form from the results is useful. You can render the filter controls at the top and loop over product_filter.qs in the body.
Filtering with Lookup Expressions
Exact matches are only the beginning. Django lookups let you filter by range, prefix, containment, and more.
You declare a field explicitly and set the lookup_expr option to control the comparison.
import django_filters from .models import Product class ProductFilter(django_filters.FilterSet): name = django_filters.CharFilter(lookup_expr="icontains") min_price = django_filters.NumberFilter(field_name="price", lookup_expr="gte") max_price = django_filters.NumberFilter(field_name="price", lookup_expr="lte") class Meta: model = Product fields = ["name", "min_price", "max_price"] The CharFilter class creates a text input. Setting icontains turns it into a case-insensitive substring match.
The NumberFilter class handles numeric input. Using gte and lte on the same field creates a price range filter.
A request like ?name=phone&min_price=100&max_price=500 now returns matching products in one query.
Common Filter Field Types
django-filter ships a field class for nearly every Django model type. Choosing the right one matters.
- CharFilter for text with lookups like contains or startswith
- NumberFilter for integers and decimals with range lookups
- BooleanFilter for true and false toggles
- DateFilter for single date comparisons
- ModelChoiceFilter for foreign key selection
- ModelMultipleChoiceFilter for many-to-many relationships
A boolean filter is often the cleanest way to model a status flag.
class ProductFilter(django_filters.FilterSet): in_stock = django_filters.BooleanFilter(field_name="in_stock") class Meta: model = Product fields = ["in_stock"] The BooleanFilter class accepts values like true or 1 and converts them into a proper Python boolean before querying.
Date Range Filtering
Date ranges come up constantly. django-filter provides a dedicated field for them.
import django_filters from .models import Order class OrderFilter(django_filters.FilterSet): created = django_filters.DateFromToRangeFilter() class Meta: model = Order fields = ["created", "status"] The DateFromToRangeFilter class renders two inputs, one for the start date and one for the end date.
Clients pass values like ?created_after=2026-01-01&created_before=2026-10-14 and receive only the orders inside that window.
This is far cleaner than parsing two separate date strings yourself and handling every malformed input case.
Filtering Related Models
Relationships need a little extra care. A foreign key filter should offer valid choices, not a free text box.
import django_filters from .models import Product, Manufacturer class ProductFilter(django_filters.FilterSet): manufacturer = django_filters.ModelChoiceFilter( queryset=Manufacturer.objects.all() ) class Meta: model = Product fields = ["manufacturer"] The ModelChoiceFilter class validates that the submitted ID exists. Invalid IDs return an empty result instead of raising an error.
You can also traverse the relationship with a double underscore in the field name. That lets users filter on a related model's attributes.
class ProductFilter(django_filters.FilterSet): manufacturer_country = django_filters.CharFilter( field_name="manufacturer__country", lookup_expr="iexact", ) class Meta: model = Product fields = ["manufacturer_country"] The field_name option uses Django's standard traversal syntax. The double underscore crosses the foreign key in a single query.
Custom Filter Methods
Sometimes a filter needs logic that no built-in field can express. You can point a filter at a method instead.
import django_filters from .models import Product class ProductFilter(django_filters.FilterSet): discounted = django_filters.BooleanFilter(method="filter_discounted") def filter_discounted(self, queryset, name, value): """Return only products with a discount when true.""" if value: return queryset.filter(discount_price__lt=models.F("price")) return queryset class Meta: model = Product fields = ["discounted"] The method receives the current queryset, the field name, and the submitted value. Return the modified queryset and django-filter handles the rest.
This pattern keeps complex business rules inside the filter class instead of scattering them across views.
Using django-filter with Django REST Framework
DRF integration is where django-filter shines. The package provides a backend that plugs directly into generic views.
from rest_framework import generics from django_filters import rest_framework as filters from .models import Product from .serializers import ProductSerializer from .filters import ProductFilter class ProductList(generics.ListAPIView): queryset = Product.objects.all() serializer_class = ProductSerializer filter_backends = (filters.DjangoFilterBackend,) filterset_class = ProductFilter The DjangoFilterBackend class reads the query string and applies the FilterSet before serialization. Clients get filtering for free on any list endpoint.
For simple cases you can skip the FilterSet entirely and declare the fields inline.
class ProductList(generics.ListAPIView): queryset = Product.objects.all() serializer_class = ProductSerializer filter_backends = (filters.DjangoFilterBackend,) filterset_fields = ("category", "in_stock") This shortcut is convenient, but a real FilterSet scales better once lookups and custom methods enter the picture.
Filtering interacts closely with pagination and ordering. Getting it wrong can produce slow endpoints under load. If you are tuning a large API, review these Django Rest Framework best practices for scalable APIs to keep query counts low.
Testing Your Filters
Filters break quietly during refactors. A renamed field or a changed lookup can silently return the wrong data.
Write a test for each filter that matters. Pass query parameters and assert the returned queryset.
import pytest from .filters import ProductFilter from .models import Product @pytest.mark.django_db def test_filter_in_stock(): Product.objects.create(name="A", in_stock=True) Product.objects.create(name="B", in_stock=False) product_filter = ProductFilter({"in_stock": "true"}) assert product_filter.qs.count() == 1 The test creates two rows, applies the filter, and checks the count. It is short and catches real regressions.
If you are new to pytest, this walkthrough on how to install pytest-django in Python step by step covers the setup from scratch. Once configured, tests like the one above run in milliseconds.
Performance Considerations
Filtering happens in the database, so the queryset stays efficient. Still, a few habits prevent surprises.
Use select_related or prefetch_related when a filter traverses a relationship and the template reads related fields.
Avoid filters that force a full table scan. An unindexed column with an icontains lookup will not use a standard index.
Log the generated SQL during development. Django's query logging shows exactly what each filter produces.
If a filtered list becomes slow under real traffic, the problem is usually the surrounding view, not django-filter. Query optimization work pays off here.
Common Mistakes
Leaving fields wide open. The Meta.fields list should be explicit. Using fields = "__all__" exposes every column, including sensitive ones.
Forgetting to register the app. Without "django_filters" in INSTALLED_APPS, the filter widgets fail to render and errors appear at runtime.
Confusing the package name and app name. The pip package is django-filter with a hyphen. The app label is django_filters with an underscore.
Putting filter logic in the view. Keep FilterSets in a dedicated filters.py file. Views should stay thin.
Ignoring empty results. An invalid parameter returns an empty queryset, not an error. Handle that in the template so users understand what happened.
Skipping validation tests. A filter that accepts garbage silently is worse than one that rejects it loudly.
Best Practices
Declare filters explicitly. Named fields document intent and make the code reviewable.
Group related filters into one FilterSet per model. Splitting them across files causes duplication.
Use lookup expressions instead of custom methods when a built-in lookup already fits.
Reuse FilterSets across views. A list page and an API endpoint can share the same filtering logic.
Keep the filter file tidy as it grows. If you use isort or a similar tool, imports stay ordered without manual effort.
Conclusion
django-filter turns filtering from a pile of conditionals into a short declarative class. You define fields, set lookups, and the package builds the query.
Start with a basic FilterSet and a couple of fields. Add lookup expressions for ranges and partial matches. Reach for custom methods only when built-in fields fall short.
Then wire it into DRF and test the results. Your endpoints gain flexible filtering without a single extra branch in the view.
Do that once, and you will reach for django-filter on every Django project after this one. If you ever need to deploy a Django app with Fabric, your filter logic travels with the code, clean and portable.