A clean, flexible Django queryset filtering library driven by query-string params.
Drop it into any Django or DRF view and turn URL parameters into ORM queries — zero boilerplate.
GET /products?name__icontains=shirt&price__gte=100&is_active=true
from django_filterly import FilterSet
def product_list(request):
qs = FilterSet(
queryset=Product.objects.all(),
params=request.GET,
).qs
return JsonResponse(list(qs.values()), safe=False)
# Equivalent to:
# Product.objects.filter(name__icontains="shirt")
# .filter(price__gte=100)
# .filter(is_active=True)That's it — no manual parsing, no type casting, no repetitive if "field" in request.GET blocks.
- Zero-Boilerplate Filtering from query params — no manual parsing
- Automatic Type Coercion — bool, int, float, date, datetime, list, range, UUID
- Deep Relation Filtering —
user__profile__city__icontains=Cairo - Built-In Validation with structured errors and machine-readable codes
- Works With Django & DRF out of the box — no extra setup
- No Config, No Magic Classes — just pass
request.GETand go
| Without filterly | With filterly |
|---|---|
Parse request.GET by hand |
One FilterSet(queryset, request.GET).qs call |
Write repetitive if "field" in request.GET blocks |
Automatic type coercion, validation, and ORM mapping |
| Risk invalid values crashing your queries | Structured errors with machine-readable codes |
| Duplicate filter logic across views | Reusable FilterSet subclasses per model |
- No serializers required — pass
request.GETdirectly, noFilterSetclass declaration needed - No config-heavy classes — no
Meta, nofields = [...], no per-fieldFilter()instances - Works directly with query params — drop it into any view or script with a plain dict
- Zero setup — no
INSTALLED_APPSentry, no migration, no wiring
pip install django-filterlyNo extra settings needed — filterly has zero Django app registration. Just import and use.
Requirements: Python ≥ 3.8 · Django ≥ 3.2
| Lookup | SQL equivalent | Example param |
|---|---|---|
exact |
= 'value' |
name__exact=T-Shirt |
iexact |
ILIKE 'value' |
name__iexact=t-shirt |
contains |
LIKE '%value%' |
name__contains=shirt |
icontains |
ILIKE '%value%' |
name__icontains=shirt |
startswith |
LIKE 'value%' |
name__startswith=T |
istartswith |
ILIKE 'value%' |
name__istartswith=t |
endswith |
LIKE '%value' |
name__endswith=Shirt |
iendswith |
ILIKE '%value' |
name__iendswith=shirt |
regex |
~ 'pattern' |
name__regex=^T.*t$ |
iregex |
~* 'pattern' |
name__iregex=^t.*t$ |
| Lookup | SQL equivalent | Example param |
|---|---|---|
gt |
> value |
price__gt=50 |
gte |
>= value |
price__gte=50 |
lt |
< value |
price__lt=200 |
lte |
<= value |
price__lte=200 |
| Lookup | Extracts | Example param |
|---|---|---|
date |
Date part of a datetime | created_at__date=2024-01-15 |
year |
Year integer | created_at__year=2024 |
month |
Month integer (1–12) | created_at__month=6 |
day |
Day integer (1–31) | created_at__day=15 |
week |
ISO week number | created_at__week=3 |
week_day |
1=Sun … 7=Sat | created_at__week_day=2 |
hour |
Hour (0–23) | updated_at__hour=14 |
minute |
Minute (0–59) | updated_at__minute=30 |
second |
Second (0–59) | updated_at__second=0 |
| Lookup | Behaviour | Example param |
|---|---|---|
in |
Field value is one of a list | status__in=active,pending,review |
range |
BETWEEN two values (inclusive) | price__range=10,500 |
| Lookup | Behaviour | Example param |
|---|---|---|
isnull |
IS NULL / IS NOT NULL | deleted_at__isnull=true |
| Lookup | Behaviour | Example param |
|---|---|---|
not |
.exclude(field__exact=value) |
status__not=draft |
| Lookup | Behaviour | Example param |
|---|---|---|
contains |
JSON subset match | meta__contains={"color":"red"} |
has_key |
Key exists | meta__has_key=color |
has_keys |
All keys present | meta__has_keys=color,size |
has_any_keys |
Any key present | meta__has_any_keys=color,size |
| Lookup | Behaviour | Example param |
|---|---|---|
contains |
Array contains all values | tags__contains=sale,new |
overlap |
Array shares ≥1 value | tags__overlap=sale,promo |
len |
Array length equals N | tags__len=3 |
qs = FilterSet(
queryset=Product.objects.all(),
params={"price__gte": "50", "is_active": "true"},
).qsfilterly accepts any of these for True / False:
true 1 yes on → True
false 0 no off → False
?deleted_at__isnull=true → WHERE deleted_at IS NULL
?deleted_at__isnull=false → WHERE deleted_at IS NOT NULL
?status__not=draft → .exclude(status__exact="draft")
# Comma-separated string
?status__in=active,pending,review
# Multi-value QueryDict (?status=active&status=pending)
# auto-promoted to __in when the same key appears multiple times
?price__range=10,500 → WHERE price BETWEEN 10 AND 500
?created_at__range=2024-01-01,2024-12-31
?created_at__date=2024-01-15
?created_at__year=2024
?created_at__gte=2024-01-01T00:00:00
?created_at__lte=2024-12-31T23:59:59Z ← Z suffix handled
?owner__email__icontains=gmail.com
?user__age__gte=18
Three or more underscore-separated segments where the last is a known lookup:
?owner_email_icontains=gmail.com → owner__email__icontains
?user_age_gte=18 → user__age__gte
Plain two-segment fields (is_active, created_at, user_id) are not treated as flat notation.
?owner__profile__city__icontains=Cairo
?meta__has_key=color
?meta__has_keys=color,size
?meta__has_any_keys=color,size
?meta__contains={"color": "red"}
?tags__contains=sale,new
?tags__overlap=sale,promo
?tags__len=3
def parse_price(value, *, field, lookup):
cleaned = str(value).lstrip("$€£").strip()
try:
return float(cleaned)
except ValueError:
raise FilterValueError(
f"Cannot parse '{value}' as a price.",
field=field, lookup=lookup, value=value,
expected_type="float", code="invalid_price",
)
qs = FilterSet(
queryset=Product.objects.all(),
params={"price__gte": "$100.00"},
custom_transformers={"price": parse_price},
).qsfrom rest_framework.generics import ListAPIView
from django_filterly import FilterSet
class ProductListView(ListAPIView):
serializer_class = ProductSerializer
def get_queryset(self):
qs = FilterSet(
queryset=Product.objects.all(),
params=self.request.GET,
allowed_fields={"name", "price", "is_active", "created_at"},
allowed_lookups={"exact", "icontains", "gte", "lte", "range"},
).qs
return qsclass ProductFilterSet(FilterSet):
_FIELDS = {
"name", "price", "stock", "is_active",
"created_at", "updated_at", "owner__email",
}
_LOOKUPS = {
"exact", "icontains", "gt", "gte", "lt", "lte",
"range", "in", "isnull", "not", "year", "month",
}
def __init__(self, params, *, queryset=None, **kwargs):
super().__init__(
queryset=queryset or Product.objects.all(),
params=params,
allowed_fields=kwargs.pop("allowed_fields", self._FIELDS),
allowed_lookups=kwargs.pop("allowed_lookups", self._LOOKUPS),
**kwargs,
)
# In a view:
qs = ProductFilterSet(request.GET).qsallowed_fields and allowed_lookups are optional.
- Omit them → filterly accepts any field that exists on the model and any supported lookup. Good for internal tools or trusted clients.
- Set them → filterly rejects anything outside the whitelist with a
FilterValidationError. Use this in public APIs to prevent callers from filtering on sensitive fields (e.g.password,secret_token) or using expensive lookups (e.g.regex).
# No whitelist — all model fields and all supported lookups accepted
qs = FilterSet(
queryset=Product.objects.all(),
params=request.GET,
).qs
# With whitelist — anything outside raises FilterValidationError
qs = FilterSet(
queryset=Product.objects.all(),
params=request.GET,
allowed_fields={"price", "status", "created_at"}, # only these fields
allowed_lookups={"exact", "gte", "lte", "in"}, # only these lookups
).qsfrom django_filterly import FilterSet, FilterValidationError, FilterValueError
def product_list(request):
"""
Examples:
?name__icontains=shirt
?price__range=10,500
?is_active=true
?created_at__year=2024
?status__in=active,pending
?deleted_at__isnull=true
?status__not=draft
?owner__email__icontains=gmail
"""
try:
qs = FilterSet(
queryset=Product.objects.select_related("owner").all(),
params=request.GET,
allowed_fields={"name", "price", "is_active", "status",
"created_at", "deleted_at", "owner__email"},
allowed_lookups={"exact", "icontains", "gte", "lte", "range",
"in", "isnull", "not", "year"},
).qs
return JsonResponse(list(qs.values()), safe=False)
except FilterValidationError as e:
# e.field, e.lookup, e.code → "field_not_allowed" | "lookup_incompatible" | …
return HttpResponseBadRequest(str(e))
except FilterValueError as e:
# e.field, e.value, e.expected_type, e.code → "invalid_int" | "invalid_date" | …
return HttpResponseBadRequest(str(e))| Code | Exception | When raised |
|---|---|---|
field_not_allowed |
FilterValidationError |
Field not in allowed_fields |
lookup_not_supported |
FilterValidationError |
Lookup unknown to filterly |
lookup_not_allowed |
FilterValidationError |
Lookup not in allowed_lookups |
lookup_incompatible |
FilterValidationError |
Lookup wrong for field's data type |
field_does_not_exist |
FilterValidationError |
Field not on the model |
not_a_relation |
FilterValidationError |
FK traversal on a non-FK field |
invalid_bool |
FilterValueError |
Value not a recognised boolean |
invalid_int |
FilterValueError |
Value not a valid integer |
invalid_float |
FilterValueError |
Value not a valid float |
invalid_number |
FilterValueError |
Value not a valid number |
invalid_date |
FilterValueError |
Value not ISO 8601 date |
invalid_datetime |
FilterValueError |
Value not ISO 8601 datetime |
invalid_time |
FilterValueError |
Value not a valid time |
invalid_uuid |
FilterValueError |
Value not a valid UUID |
invalid_range |
FilterValueError |
Range does not have exactly 2 values |
empty_list |
FilterValueError |
__in list is empty |
null_value |
FilterValueError |
None passed for a non-nullable field |
A shopper hits this URL:
GET /products?price__gte=100&price__lte=500&is_active=true&category__name__icontains=shoes&stock__gt=0&created_at__year=2024&tags__overlap=sale,new&ordering=price
filterly handles all 7 filter params in one call:
from django_filterly import FilterSet, FilterValidationError, FilterValueError
from django.http import JsonResponse, HttpResponseBadRequest
def product_list(request):
try:
qs = FilterSet(
queryset=Product.objects.select_related("category").all(),
params=request.GET,
allowed_fields={
"price", "is_active", "stock", "created_at",
"category__name", "tags",
},
allowed_lookups={
"gte", "lte", "gt", "exact",
"icontains", "year", "overlap",
},
).qs
return JsonResponse(list(qs.values()), safe=False)
except FilterValidationError as e:
return HttpResponseBadRequest({"error": str(e), "field": e.field, "code": e.code})
except FilterValueError as e:
return HttpResponseBadRequest({"error": str(e), "field": e.field, "code": e.code})What filterly does under the hood for that URL:
| Param | Parsed as | ORM call |
|---|---|---|
price__gte=100 |
field=price, lookup=gte, value=100.0 |
.filter(price__gte=100.0) |
price__lte=500 |
field=price, lookup=lte, value=500.0 |
.filter(price__lte=500.0) |
is_active=true |
field=is_active, lookup=exact, value=True |
.filter(is_active=True) |
category__name__icontains=shoes |
field=category__name, lookup=icontains |
.filter(category__name__icontains="shoes") |
stock__gt=0 |
field=stock, lookup=gt, value=0 |
.filter(stock__gt=0) |
created_at__year=2024 |
field=created_at, lookup=year, value=2024 |
.filter(created_at__year=2024) |
tags__overlap=sale,new |
field=tags, lookup=overlap, value=["sale","new"] |
.filter(tags__overlap=["sale","new"]) |
Note:
orderingis not a filter param — filterly ignores any param that doesn't match a field + lookup pattern.
→ You can safely mix filtering with pagination, ordering, or any other custom query params in the same URL.
You can use individual modules directly when you only need part of the pipeline.
from django_filterly import parse_params
descriptors = parse_params({"price__gte": "100", "name__icontains": "shirt"})
for d in descriptors:
print(d.field, d.lookup, d.value, d.exclude)
# price gte 100 False
# name icontains shirt Falsefrom django_filterly import FilterDescriptor, validate_filter
validate_filter(
FilterDescriptor(field="price", lookup="gte", value="100"),
model=Product,
allowed_fields={"price", "name"},
allowed_lookups={"gte", "lte", "icontains"},
)from django_filterly import to_bool, to_date, to_int
to_int("42", field="age", lookup="gte") # → 42
to_bool("yes", field="is_active", lookup="exact") # → True
to_date("2024-01-15", field="created_at", lookup="date") # → date(2024, 1, 15)django-filterly/
├── django_filterly/
│ ├── __init__.py
│ ├── constants.py # lookup groups and type→lookup maps
│ ├── exceptions.py # FilterValidationError, FilterValueError
│ ├── helpers.py # field resolution and type detection
│ ├── parser.py # FilterDescriptor + parse_params()
│ ├── transformers.py # type coercion functions
│ ├── validators.py # validate_filter() / validate_all()
│ └── filterset.py # FilterSet — the main public API
├── tests/
│ └── test_filters.py
├── examples/
│ └── basic_usage.py
├── run_tests.py
└── pyproject.toml
# Clone and set up
git clone https://github.com/esraamashaal/django-filterly.git
cd django-filterly
# Create and activate virtual environment
python -m venv .venv
.venv\Scripts\activate # Windows
source .venv/bin/activate # macOS / Linux
# Install dev dependencies
pip install -e ".[dev]"
# Run all tests with coverage
python run_tests.py
# Or with pytest directly
pytest tests/ -v
pytest tests/ -v --tb=short --cov=django_filterly --cov-report=term-missing
# Run a specific test class
pytest tests/ -k TestParser -v
pytest tests/ -k TestFilterSet -vMIT — see LICENSE.
|
Esraa Raffik Mashaal Senior Software Engineer |