FilterSet¶
Complete guide to FilterSet covering everything from basics to advanced usage, Meta options, ordering, fields overview, PostgreSQL features, and all important caveats.
Table of Contents¶
- What is FilterSet
- Creating FilterSets
- Meta Options
- Field Overview
- Using FilterSets
- Ordering
- Operators
- Preprocessors
- Postprocessors
- Validation
- PostgreSQL Features
- Important Caveats
- Best Practices
What is FilterSet¶
FilterSet is the core class in drf-restflow. It validates query parameters and applies filters to Django querysets using a declarative, type-safe syntax.
from restflow.filters import FilterSet
class ProductFilterSet(FilterSet):
name: str
price: int
# Usage: ?name=laptop&price=999
Creating FilterSets¶
Type Annotations (Simplest)¶
Explicit Field Declarations¶
from restflow.filters import FilterSet, StringField, IntegerField
class ProductFilterSet(FilterSet):
name = StringField(lookups=["icontains"])
price = IntegerField(lookups=["comparison"], min_value=0)
category = IntegerField(lookup_expr="category__id")
Model-Based Generation¶
class ProductFilterSet(FilterSet):
class Meta:
model = Product
fields = ['name', 'price', 'category'] # Specific fields
# or
fields = '__all__' # All model fields
Mixed Approach¶
Combine custom fields with model-based generation:
class ProductFilterSet(FilterSet):
# Custom fields
search = StringField(method="filter_search")
trending = BooleanField(method="filter_trending")
# Model-based fields
class Meta:
model = Product
fields = ['category', 'in_stock', 'price']
def filter_search(self, filterset, queryset, value):
return Q(name__icontains=value) | Q(description__icontains=value)
def filter_trending(self, filterset, queryset, value):
if value:
week_ago = timezone.now() - timedelta(days=7)
return Q(created_at__gte=week_ago, views__gte=100)
return Q()
InlineFilterSet (Dynamic)¶
Create FilterSets dynamically without class definition:
from restflow.filters import InlineFilterSet
ProductFilterSet = InlineFilterSet(
model=Product,
fields=['name', 'price', 'category']
)
# Or with more options
ProductFilterSet = InlineFilterSet(
model=Product,
fields=['name', 'price'],
extra_kwargs={
'name': {'lookups': ['icontains']},
'price': {'min_value': 0}
}
)
Meta Options¶
The Meta class configures FilterSet behavior. All options are optional.
Complete Meta Options Reference¶
class ProductFilterSet(FilterSet):
class Meta:
# Model configuration
model = Product # Django model to generate fields from
fields = ['name', 'price'] # Fields to include ('__all__' for all)
exclude = ['internal_id'] # Fields to exclude
# Field configuration
extra_kwargs = { # Configure fields without explicit declaration
'name': {
'lookups': ['icontains', 'istartswith'],
'required': True,
'min_length': 2,
'help_text': 'Product name'
},
'price': {
'lookups': ['comparison'],
'min_value': 0,
'max_value': 1000000
}
}
# Operator
operator = "AND" # Combine filters with AND/OR/XOR (default: AND)
# Ordering
order_fields = [ # Fields available for ordering
('name', 'name'), # (model_field, query_param)
('price', 'price'),
('created_at', 'created_at'),
]
default_order_fields = ["price"] # Default order field
order_param = "order_by" # Order query parameter name
override_order_dir = "asc" # Overrides an order direction, if set to "desc" then `-field` will be in ascending order
# Processors
preprocessors = [ # Functions to run before filtering
exclude_deleted,
apply_permissions,
]
postprocessors = [ # Functions to run after filtering
apply_default_ordering,
ensure_distinct,
]
model¶
Specify the Django model to generate fields from:
fields¶
Specify which fields to include:
# Specific fields
class Meta:
model = Product
fields = ['name', 'price', 'category']
# All fields
class Meta:
model = Product
fields = '__all__'
# No fields (only custom fields)
class Meta:
model = Product
fields = []
exclude¶
Exclude specific fields from generation:
extra_kwargs¶
Configure fields without explicit declarations:
class Meta:
model = Product
fields = ['name', 'price', 'category', 'status']
extra_kwargs = {
'name': {
'lookups': ['icontains', 'istartswith'], # Add lookup variations
'required': True, # Make required
'min_length': 2, # Validation
'max_length': 200,
'help_text': 'Product name to search'
},
'price': {
'lookups': ['comparison'], # Add gt, gte, lt, lte
'min_value': 0, # Must be >= 0
'max_value': 1000000, # Must be <= 1000000
'validators': [custom_validator] # Custom validators
},
'category': {
'lookup_expr': 'category__id', # Custom lookup expression
'required': False
},
'status': {
'choices': [ # Limit to choices
('draft', 'Draft'),
('published', 'Published')
]
}
}
extra_kwargs options:
- lookups: List of lookup expressions to generate
- lookup_expr: Custom lookup expression
- required: Make field required
- min_value, max_value: Numeric validation
- min_length, max_length: String validation
- validators: List of custom validators
- choices: Limit to specific choices
- help_text: Description for API documentation
- method: Custom filter method name
- Any other DRF field parameter
operator¶
Control how filters are combined (default: "AND"):
# All filters must match (default)
class Meta:
operator = "AND"
# Any filter can match
class Meta:
operator = "OR"
# Exactly one filter must match
class Meta:
operator = "XOR"
See Operators section for details.
order_fields¶
Define which fields can be used for ordering:
class Meta:
order_param = "sort_by" # Query param responsible for ordering
order_fields = [
('name', 'name'), # Can order by:`?sort_by=name` or `?sort_by=-name`
('price', 'price'),
('created_at', 'created_at'),
('review_count', 'reviews'), # Annotated field
]
default_order_fields = ["price"] # If the value is empty, the queryset will be ordered by price
order_field_labels = [("Item Name", "name")] # For schema generation / viewing
override_order_dir = "desc" # This will reverse the ordering, queryset.order_by("-price"), will order by price in ascending order
# and .order_by("price") will sort by price in descending order
See Ordering section for details.
preprocessors¶
Functions that run before filters are applied:
def exclude_deleted(filterset, queryset):
return queryset.filter(deleted_at__isnull=True)
class Meta:
preprocessors = [exclude_deleted]
See Preprocessors section for details.
postprocessors¶
Functions that run after filters are applied:
def apply_default_ordering(filterset, queryset):
if not queryset.ordered:
return queryset.order_by('-created_at')
return queryset
class Meta:
postprocessors = [apply_default_ordering]
See Postprocessors section for details.
Field Overview¶
FilterSet supports various field types. See Fields for complete details.
Available Field Types¶
from restflow.filters import (
# Basic types
StringField, IntegerField, FloatField, BooleanField, DecimalField,
# Date/Time
DateField, DateTimeField, TimeField,
# Choices
ChoiceField, MultipleChoiceField,
# Lists
ListField,
# Special
OrderField,
)
Type Annotations¶
Use Python type annotations for automatic field generation:
from typing import List, Literal
from datetime import datetime
class ProductFilterSet(FilterSet):
# Basic types
name: str # StringField
price: int # IntegerField
rating: float # FloatField
in_stock: bool # BooleanField
# Date/Time
created_at: datetime # DateTimeField
# Choices with Literal
status: Literal["draft", "published"] # ChoiceField
# Lists
tags: List[int] # ListField with IntegerField child
categories: List[str] # ListField with StringField child
Lookups¶
Fields can have lookup variations:
class ProductFilterSet(FilterSet):
# Single lookup
name = StringField(lookups=["icontains"])
# Creates: name__icontains
# Multiple lookups
price = IntegerField(lookups=["gte", "lte"])
# Creates: price__gte, price__lte
# Lookup categories
title = StringField(lookups=["text"])
# Creates: title__icontains, title__contains, title__startswith,
# title__endswith, title__iexact
# Comparison category
views = IntegerField(lookups=["comparison"])
# Creates: views__gt, views__gte, views__lt, views__lte
Custom Lookup Expressions¶
class ProductFilterSet(FilterSet):
# Filter by related field
category_name = StringField(lookup_expr="category__name__icontains")
# Nested relationships
department = StringField(lookup_expr="category__department__name")
# Multiple levels
region = StringField(lookup_expr="store__address__city__region__name")
Negation¶
All filters automatically support negation with !:
# No configuration needed - automatic!
# ?status!=draft # NOT draft
# ?price__gte!=1000 # NOT >= 1000
# ?name__icontains!=test # NOT containing test
Using FilterSets¶
In Views¶
from rest_framework import generics
from rest_framework.exceptions import ValidationError
class ProductListView(generics.ListAPIView):
serializer_class = ProductSerializer
def get_queryset(self):
queryset = Product.objects.all()
filterset = ProductFilterSet(request=self.request)
if not filterset.is_valid():
raise ValidationError(filterset.errors)
return filterset.filter_queryset(queryset)
filter_queryset() Method¶
The filter_queryset() method applies all filters to a queryset:
filterset = ProductFilterSet(request=request)
filtered_qs = filterset.filter_queryset(Product.objects.all())
With ignore parameter:
Skip specific filters when applying:
# Ignore certain filters
filtered_qs = filterset.filter_queryset(
Product.objects.all(),
ignore=['search', 'trending']
)
# Useful when you want to apply some filters manually
# or conditionally skip certain filters
Example use case:
class ProductListView(generics.ListAPIView):
def get_queryset(self):
queryset = Product.objects.all()
filterset = ProductFilterSet(request=self.request)
# Apply all filters except 'search'
# We'll handle search separately
queryset = filterset.filter_queryset(queryset, ignore=['search'])
# Custom search logic with highlighting
if 'search' in filterset.data:
search_term = filterset.data['search']
queryset = self.apply_custom_search(queryset, search_term)
return queryset
From Dictionary¶
Accessing Data¶
filterset = ProductFilterSet(request=request)
# Check if valid
if filterset.is_valid():
# Get validated data
data = filterset.validated_data
# {'name': 'laptop', 'price__gte': 100}
else:
# Get errors
errors = filterset.errors
# {'price': ['A valid integer is required.']}
# Get as dictionary
# Automatically does `.is_valid()`
data = filterset.model_dump()
Ordering¶
Add ordering/sorting to your FilterSet.
Using Meta.order_fields¶
class ProductFilterSet(FilterSet):
name: str
price: int
class Meta:
# If the value is empty, the queryset will be ordered by price
order_param = "sort_by" # Query param responsible for ordering, by default set to 'order_by'
order_fields = [
('name', 'name'), # Can order by:`?sort_by=name` or `?sort_by=-name`
('price', 'price'),
('created_at', 'created_at'),
('review_count', 'reviews'), # Annotated field
]
default_order_fields = ["price"]
order_field_labels = [("Item Name", "name")] # For schema generation / viewing
override_order_dir = "asc"
# Usage:
# ?sort_by=name # Ascending
# ?sort_by=-name # Descending
# ?sort_by=price # By price ascending
# ?sort_by=-created_at # Newest first
Overriding order direction.¶
class ProductFilterSet(FilterSet):
name: str
price: int
class Meta:
order_fields = [
('name', 'name'), # Can order by:`?sort_by=name` or `?sort_by=-name`
('price', 'price'),
('created_at', 'created_at'),
('review_count', 'reviews'), # Annotated field
]
override_order_dir = "desc"
# Usage:
# ?order_by=name # Descending
# ?order_by=-name # Ascending
# ?order_by=price # By price descending
# ?order_by=-created_at # Oldest first
Ordering by Annotated Fields¶
from django.db.models import Count
def add_annotations(filterset, queryset):
return queryset.annotate(
review_count=Count('reviews')
)
class ProductFilterSet(FilterSet):
class Meta:
preprocessors = [add_annotations]
order_fields = [
('name', 'name'),
('review_count', 'reviews'), # Order by annotation
]
# Usage:
# ?order_by=reviews # Least reviews first
# ?order_by=-reviews # Most reviews first
Using OrderField Explicitly¶
from restflow.filters import OrderField
class ProductFilterSet(FilterSet):
ordering = OrderField(
fields=[
('name', 'name'),
('price', 'price'),
('created_at', 'created_at'),
]
)
# Usage same as Meta.order_fields
# ?ordering=name
# ?ordering=-price
Default Ordering with Postprocessor¶
class ProductFilterSet(FilterSet):
class Meta:
default_order_fields = ["price"]
order_fields = [('name', 'name'), ('created_at', 'created_at')]
postprocessors = [apply_default_ordering]
# Queries without ?order_by get default ordering by price
Operators¶
Operators control how multiple filters are combined.
AND Operator (Default)¶
All conditions must match:
class ProductFilterSet(FilterSet):
name: str
category: str
class Meta:
operator = "AND" # Default
# ?name=laptop&category=electronics
# SQL: WHERE name='laptop' AND category='electronics'
OR Operator¶
Any condition can match:
class ProductFilterSet(FilterSet):
name: str
description: str
class Meta:
operator = "OR"
# ?name__icontains=wireless&description__icontains=bluetooth
# SQL: WHERE name ILIKE '%wireless%' OR description ILIKE '%bluetooth%'
XOR Operator¶
Exactly one condition must match:
class ProductFilterSet(FilterSet):
is_new: bool
is_refurbished: bool
class Meta:
operator = "XOR"
# ?is_new=true&is_refurbished=true
# Returns items that are EITHER new OR refurbished (not both)
Operator with Custom Methods¶
⚠️ CRITICAL CAVEAT: Operators only work correctly when custom methods return Q objects:
class ProductFilterSet(FilterSet):
in_stock = BooleanField(method="filter_in_stock")
category: str
class Meta:
operator = "OR"
# ✅ CORRECT - Returns Q object
def filter_in_stock(self, filterset, queryset, value):
if value:
return Q(inventory__gt=0)
return Q()
# ❌ WRONG - Returns QuerySet (operator ignored!)
def filter_in_stock_wrong(self, filterset, queryset, value):
if value:
return queryset.filter(inventory__gt=0)
return queryset
See Custom Method Caveat for details.
Preprocessors¶
Preprocessors transform querysets before filters are applied.
Basic Usage¶
def exclude_deleted(filterset, queryset):
"""Always exclude soft-deleted items"""
return queryset.filter(deleted_at__isnull=True)
class ProductFilterSet(FilterSet):
name: str
class Meta:
preprocessors = [exclude_deleted]
Multiple Preprocessors¶
Run in order declared:
def exclude_deleted(filterset, queryset):
return queryset.filter(deleted_at__isnull=True)
def apply_permissions(filterset, queryset):
if filterset.request and not filterset.request.user.is_staff:
return queryset.filter(status='published')
return queryset
def optimize_queries(filterset, queryset):
return queryset.select_related('category').prefetch_related('tags')
class ProductFilterSet(FilterSet):
class Meta:
preprocessors = [
exclude_deleted, # 1. Filter deleted
apply_permissions, # 2. Apply permissions
optimize_queries, # 3. Optimize
]
Adding Annotations¶
from django.db.models import Count, Avg
def add_review_stats(filterset, queryset):
return queryset.annotate(
review_count=Count('reviews'),
avg_rating=Avg('reviews__rating')
)
class ProductFilterSet(FilterSet):
min_reviews = IntegerField(method="filter_min_reviews")
min_rating = FloatField(method="filter_min_rating")
class Meta:
preprocessors = [add_review_stats]
def filter_min_reviews(self, filterset, queryset, value):
return Q(review_count__gte=value)
def filter_min_rating(self, filterset, queryset, value):
return Q(avg_rating__gte=value)
Request-Based Filtering¶
def tenant_isolation(filterset, queryset):
"""Multi-tenant data isolation"""
if not filterset.request or not filterset.request.user.is_authenticated:
return queryset.none()
tenant = filterset.request.user.tenant
return queryset.filter(tenant=tenant)
class ProductFilterSet(FilterSet):
class Meta:
preprocessors = [tenant_isolation]
Conditional Optimization¶
def smart_optimization(filterset, queryset):
"""Only optimize what's needed"""
# Always select related ForeignKeys
queryset = queryset.select_related('category', 'brand')
# Conditionally prefetch M2M
if 'tags' in filterset.data:
queryset = queryset.prefetch_related('tags')
if 'reviews' in filterset.request.query_params:
queryset = queryset.prefetch_related('reviews')
return queryset
class ProductFilterSet(FilterSet):
class Meta:
preprocessors = [smart_optimization]
Postprocessors¶
Postprocessors transform querysets after filters are applied.
Basic Usage¶
def apply_default_ordering(filterset, queryset):
if not queryset.ordered:
return queryset.order_by('-created_at')
return queryset
class ProductFilterSet(FilterSet):
class Meta:
postprocessors = [apply_default_ordering]
Ensure Distinct¶
def ensure_distinct(filterset, queryset):
"""Remove duplicates from M2M filtering"""
return queryset.distinct()
class ProductFilterSet(FilterSet):
tags: List[int]
class Meta:
postprocessors = [ensure_distinct]
Audit Logging¶
import logging
logger = logging.getLogger(__name__)
def log_filter_usage(filterset, queryset):
if filterset.request:
user = getattr(filterset.request.user, 'username', 'anonymous')
filters = dict(filterset.data)
logger.info(f"User {user} filtered: {filters}")
return queryset
class ProductFilterSet(FilterSet):
class Meta:
postprocessors = [log_filter_usage]
Performance Monitoring¶
import time
import logging
logger = logging.getLogger(__name__)
def monitor_performance(filterset, queryset):
start = time.time()
count = queryset.count()
duration = time.time() - start
if duration > 1.0:
logger.warning(
f"Slow query: {duration:.2f}s for {count} results. "
f"Filters: {dict(filterset.data)}"
)
return queryset
class ProductFilterSet(FilterSet):
class Meta:
postprocessors = [monitor_performance]
Validation¶
Automatic Validation¶
FilterSet uses DRF serializers for automatic type validation:
class ProductFilterSet(FilterSet):
price: int # Only accepts integers
# ?price=abc → {"price": ["A valid integer is required."]}
Field-Level Validation¶
from rest_framework.validators import MinValueValidator
class ProductFilterSet(FilterSet):
price = IntegerField(
min_value=0,
max_value=1_000_000,
validators=[MinValueValidator(0)]
)
# ?price=-10 → {"price": ["Ensure this value is greater than or equal to 0."]}
FilterSet-Level Validation¶
from rest_framework.exceptions import ValidationError
class ProductFilterSet(FilterSet):
min_price = IntegerField(lookup_expr="price__gte")
max_price = IntegerField(lookup_expr="price__lte")
def validate(self, data):
if 'min_price' in data and 'max_price' in data:
if data['min_price'] > data['max_price']:
raise ValidationError({
'max_price': 'Must be greater than min_price'
})
return data
# ?min_price=1000&max_price=500 → 400 Bad Request
Custom Validators¶
from rest_framework.exceptions import ValidationError
def validate_even(value):
if value % 2 != 0:
raise ValidationError("Must be an even number")
class ProductFilterSet(FilterSet):
batch_size = IntegerField(validators=[validate_even])
PostgreSQL Features¶
drf-restflow supports PostgreSQL-specific features. See Fields for complete PostgreSQL field details.
Full-Text Search¶
from django.contrib.postgres.search import SearchVector, SearchQuery, SearchRank
class ProductFilterSet(FilterSet):
search = StringField(method="filter_fulltext_search")
def filter_fulltext_search(self, filterset, queryset, value):
vector = SearchVector('name', weight='A') + SearchVector('description', weight='B')
query = SearchQuery(value)
return queryset.annotate(
search=vector,
rank=SearchRank(vector, query)
).filter(search=query).order_by('-rank')
# ?search=wireless headphones
# Uses PostgreSQL full-text search with ranking
Array Fields¶
from django.contrib.postgres.fields import ArrayField
class Product(models.Model):
tags = ArrayField(models.CharField(max_length=50))
class ProductFilterSet(FilterSet):
tags = ListField(
child=StringField(),
lookups=["pg_array"] # PostgreSQL array lookups
)
# ?tags__contains=wireless # Array contains value
# ?tags__overlap=wireless,bluetooth # Array overlaps with values
# ?tags__contained_by=a,b,c # Array contained by values
JSON Fields¶
from django.db import models
class Product(models.Model):
metadata = models.JSONField()
class ProductFilterSet(FilterSet):
# Filter by JSON key
brand = StringField(lookup_expr="metadata__brand")
color = StringField(lookup_expr="metadata__specs__color")
# ?brand=Apple
# ?color=red
Using SearchVector in Preprocessor¶
from django.contrib.postgres.search import SearchVector
def add_search_vector(filterset, queryset):
"""Add search vector for better full-text search"""
if 'search' in filterset.data:
return queryset.annotate(
search_vector=SearchVector('name', 'description', 'tags')
)
return queryset
class ProductFilterSet(FilterSet):
search = StringField(method="filter_search")
class Meta:
preprocessors = [add_search_vector]
def filter_search(self, filterset, queryset, value):
from django.contrib.postgres.search import SearchQuery
return queryset.filter(search_vector=SearchQuery(value))
Important Caveats¶
Custom Method Caveat¶
⚠️ CRITICAL: When custom methods return QuerySet instead of Q objects, the FilterSet operator is NOT applied.
class ProductFilterSet(FilterSet):
in_stock = BooleanField(method="filter_in_stock")
category: str
class Meta:
operator = "OR" # ⚠️ Won't apply to QuerySet returns!
# ❌ WRONG - Returns QuerySet
def filter_in_stock(self, filterset, queryset, value):
if value:
return queryset.filter(inventory__gt=0)
return queryset
# Query: ?in_stock=true&category=electronics
# Expected (OR): in_stock=true OR category=electronics
# Actual: in_stock=true AND category=electronics (Operator ignored!)
✅ SOLUTION: Always return Q objects:
def filter_in_stock(self, filterset, queryset, value):
if value:
return Q(inventory__gt=0)
return Q() # Empty Q matches everything
Why Q objects? - Work correctly with ALL operators (AND, OR, XOR) - Properly combined with other filters - More predictable behavior - Better for complex queries
Annotation Performance¶
❌ Don't annotate in each filter method:
def filter_min_reviews(self, filterset, queryset, value):
# Bad - annotation repeated for each call
return queryset.annotate(count=Count('reviews')).filter(count__gte=value)
✅ Annotate once in preprocessor:
def add_annotations(filterset, queryset):
return queryset.annotate(review_count=Count('reviews'))
class Meta:
preprocessors = [add_annotations]
def filter_min_reviews(self, filterset, queryset, value):
return Q(review_count__gte=value)
Request Access¶
Always check if request exists:
def user_filter(filterset, queryset):
# ✅ Check request exists
if not filterset.request:
return queryset
if not filterset.request.user.is_authenticated:
return queryset.filter(is_public=True)
return queryset
Processor Return Values¶
Always return queryset:
# ✅ Good
def my_processor(filterset, queryset):
return queryset.filter(active=True)
# ❌ Bad - returns None
def my_processor(filterset, queryset):
queryset.filter(active=True) # Missing return!
Best Practices¶
Always Return Q Objects from Custom Methods¶
# ✅ Always prefer Q objects
def filter_method(self, filterset, queryset, value):
return Q(field=value)
# ❌ Avoid QuerySet returns (unless using AND operator, or you really need to)
def filter_method(self, filterset, queryset, value):
return queryset.filter(field=value)
Use extra_kwargs for Simple Configuration¶
# ✅ Clean and maintainable
class Meta:
model = Product
fields = ['name', 'price']
extra_kwargs = {
'name': {'lookups': ['icontains']},
'price': {'min_value': 0}
}
# ❌ More verbose
name = StringField(lookups=['icontains'])
price = IntegerField(min_value=0)
Keep Processors Simple¶
# ✅ Single responsibility principle
def exclude_deleted(filterset, queryset):
return queryset.filter(deleted_at__isnull=True)
# ❌ Too many responsibilities
def do_everything(filterset, queryset):
queryset = queryset.filter(deleted_at__isnull=True)
queryset = queryset.select_related('category')
queryset = queryset.annotate(count=Count('items'))
return queryset
Processor Order Matters¶
class Meta:
preprocessors = [
exclude_deleted, # 1. Filter first
apply_permissions, # 2. Then permissions
add_annotations, # 3. Add annotations
optimize_queries, # 4. Finally optimize
]
Use ignore Parameter Wisely¶
# Useful for custom handling of specific filters
filtered_qs = filterset.filter_queryset(
queryset,
ignore=['search'] # Handle search separately with highlighting
)
Next Steps¶
- Fields - Complete guide to all field types, lookups, validation, and PostgreSQL features
- Filtering Tutorial - Step-by-step tutorial with practical examples