A clean, flexible Django queryset filtering library driven by query-string params.
Project description
django-filterly — Zero-Boilerplate Filtering for Django
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.
⚡ What You Get
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.
🚀 Key Features
- 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
✨ Why django-filterly?
| 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 |
🤔 Why not django-filter?
- 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
📦 Installation
pip install django-filterly
No extra settings needed — filterly has zero Django app registration. Just import and use.
Requirements: Python ≥ 3.10 · Django ≥ 3.2
🔍 Supported Lookups
Text / String
| 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$ |
Comparison
| 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 |
Date & Time Parts
| 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 |
Sets & Ranges
| 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 |
Null / Existence
| Lookup | Behaviour | Example param |
|---|---|---|
isnull |
IS NULL / IS NOT NULL | deleted_at__isnull=true |
Exclusion (NOT)
| Lookup | Behaviour | Example param |
|---|---|---|
not |
.exclude(field__exact=value) |
status__not=draft |
JSON Fields (PostgreSQL)
| 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 |
Array Fields (PostgreSQL)
| 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 |
📖 Usage Examples
1. Plain dict — useful in tests or scripts
qs = FilterSet(
queryset=Product.objects.all(),
params={"price__gte": "50", "is_active": "true"},
).qs
2. Boolean values
filterly accepts any of these for True / False:
true 1 yes on → True
false 0 no off → False
3. Null checks
?deleted_at__isnull=true → WHERE deleted_at IS NULL
?deleted_at__isnull=false → WHERE deleted_at IS NOT NULL
4. Exclude / NOT
?status__not=draft → .exclude(status__exact="draft")
5. IN queries — two syntaxes
# 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
6. Range queries
?price__range=10,500 → WHERE price BETWEEN 10 AND 500
?created_at__range=2024-01-01,2024-12-31
7. Date & datetime
?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
8. Foreign key traversal — ORM style
?owner__email__icontains=gmail.com
?user__age__gte=18
9. Foreign key traversal — flat shorthand
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.
10. Deep / multi-hop relations
?owner__profile__city__icontains=Cairo
11. JSON field lookups
?meta__has_key=color
?meta__has_keys=color,size
?meta__has_any_keys=color,size
?meta__contains={"color": "red"}
12. PostgreSQL array field lookups
?tags__contains=sale,new
?tags__overlap=sale,promo
?tags__len=3
13. Custom transformers — per-field value conversion
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},
).qs
14. DRF — inside a ListAPIView
from 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 qs
15. Subclassing FilterSet — reusable defaults per model
class 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).qs
🔧 Advanced Usage (Validation, Whitelisting, Errors)
Whitelisting fields and lookups
allowed_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
).qs
Full example with error handling
from 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))
Error codes reference
| 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 |
🛒 Real Example (E-commerce)
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.
🛠 Low-Level API
You can use individual modules directly when you only need part of the pipeline.
Parser only
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 False
Validator only
from 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"},
)
Individual transformers
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)
🗂 Project Structure
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
🧪 Running the Tests
# 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 -v
📄 License
MIT — see LICENSE.
👩💻 Author
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file django_filterly-0.1.0.tar.gz.
File metadata
- Download URL: django_filterly-0.1.0.tar.gz
- Upload date:
- Size: 32.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.11.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f57a57036a4f06a18cd6c4d5ec17da1757666a7c37c055f0013112faccbf6cd3
|
|
| MD5 |
e1bbf9574ce827bc40be9794f26f1f14
|
|
| BLAKE2b-256 |
fc69d9a2d21bd7b18a89faece728800d7bbb3162e33e269c6c8c37e7304d2b8b
|
File details
Details for the file django_filterly-0.1.0-py3-none-any.whl.
File metadata
- Download URL: django_filterly-0.1.0-py3-none-any.whl
- Upload date:
- Size: 20.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.11.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
54101fb618aa17d27e30023c43ce5fc4061fe0404143e9224209e0ec1174cee1
|
|
| MD5 |
82db3cffe2dda4ae49bcb3d17ea2afa1
|
|
| BLAKE2b-256 |
b324ae89a95f0d75e2b9eadcecc01636df7490e122d071c2aa7f180509964449
|