qsmongo
Turn an HTTP query string into a MongoDB query that is safe by construction — nothing is queryable until you declare it — and fast by construction: keyset pagination that costs the same on page 10,000 as on page 1.
GET /users?age__gte=21&status__in=active,trial&fields=name,age&sort=-created_at&per_page=50
from qsmongo import Field, Schema, parse
schema = Schema(
name=Field(str),
age=Field(int),
status=Field(str),
created_at=Field(datetime, alias="audit.created"),
)
query = parse(request.url.query, schema)
query.filter # {'age': {'$gte': 21}, 'status': {'$in': ['active', 'trial']}}
query.projection # {'name': 1, 'age': 1}
query.sort # [('audit.created', -1)]
collection.find(**query.find_kwargs())
No dependencies. Python 3.10+.
Why
Every REST API over MongoDB grows the same ad-hoc filter parser, and it goes wrong in the same five ways:
- Type mismatch.
?age=21gives you the string"21". Mongo does not match21against"21", so the endpoint returns an empty list and nobody can tell whether the data is missing or the filter is broken. - Operator injection. Passing user input into a query document unfiltered means a crafted
parameter can inject
$whereor$neand walk straight past your access checks. - Separator collisions. A product genuinely named
Dolce & Gabbana, a colour calledblack and white, a tag containing a comma — each one quietly truncates the filter. - Unbounded pages.
?per_page=100000is a denial-of-service vector you shipped yourself. - Offset pagination.
skip=100000makes the server walk 100,000 documents it will never return, and when the sort key has ties, documents shift between pages and are skipped or served twice.
Install
pip install qsmongo
The grammar
| Query string | Result |
|---|---|
name=Ada |
{"name": "Ada"} |
age__gte=21 |
{"age": {"$gte": 21}} |
age__gte=21&age__lt=65 |
{"age": {"$gte": 21, "$lt": 65}} |
status__in=active,trial |
{"status": {"$in": ["active", "trial"]}} |
name__contains=ada |
{"name": {"$regex": "ada", "$options": "i"}} |
name__startswith=Ad |
{"name": {"$regex": "^Ad", "$options": "i"}} |
deleted_at__exists=false |
{"deleted_at": {"$exists": False}} |
fields=name,price |
{"name": 1, "price": 1} |
fields=-internal_note |
{"internal_note": 0} |
sort=-created_at,name |
[("created_at", -1), ("name", 1)] |
page=2&per_page=50 |
skip=50, limit=50 |
after=<cursor>&per_page=50 |
keyset range clause, skip=0 |
Operators: ne gt gte lt lte in nin contains startswith endswith exists
regex. No suffix means equality.
Declaring fields
Schema(
name=Field(str), # eq/ne/in/nin/exists + contains/startswith/endswith
age=Field(int), # numbers also get gt/gte/lt/lte
email=Field(str, alias="contact.email"), # public name differs from the document field
tag=Field(str, multi=True), # ?tag=red&tag=blue -> $in
sku=Field(str, case_sensitive=True), # index-friendly startswith
internal_cost=Field(float, projectable=False), # never selectable via ?fields
description=Field(str, ops=set()), # selectable, never queryable
pattern=Field(str, ops={"eq", "regex"}), # raw regex is opt-in
)
A field that is not declared cannot be queried, sorted on, projected, or smuggled in as an
operator — parse raises UnknownField instead. That whitelist is the security model.
Keyset pagination
Offset paging is fine until it isn't. Pass a Cursors codec and clients can page by cursor:
from qsmongo import Cursors, parse
cursors = Cursors(secret=settings.CURSOR_SECRET) # HMAC-signed, so clients cannot forge one
query = parse(request.url.query, schema, cursors=cursors)
items = list(collection.find(**query.find_kwargs()))
return {"items": items, "next": query.next_cursor(items[-1] if items else None)}
The client sends that token back as ?after=<cursor>, and the skip becomes a range clause:
{"$or": [{"score": {"$lt": 42}}, {"score": 42, "_id": {"$gt": "abc"}}]}
Details that matter:
- An
_idtiebreaker is appended to every sort when cursors are enabled, so the ordering is total. Without it, two documents sharing acreated_atcan straddle a page boundary and one of them is lost. - Cursors are signed when you supply a secret, and the secret never appears in a
repr. Unsigned, tampered, or truncated tokens raiseInvalidCursor. - The sort must stay the same between pages. Changing it raises rather than silently paginating nonsense.
afterandpageare different modes; sending both is an error.- Forward-only. Backward paging is not implemented.
tests/test_keyset_property.py walks a dataset engineered so that
every page boundary lands in the middle of a tie, and asserts the pages reconstruct the full
ordering exactly — no document skipped, none repeated.
Index advice
A query that parses cleanly can still be a collection scan. analyze checks the query against your
declared indexes using MongoDB's ESR ordering — Equality keys first, then Sort keys, then
Range keys — and suggests one when nothing fits:
from qsmongo import Index, analyze
INDEXES = Index.from_index_information(collection.index_information())
advice = analyze(query, INDEXES, extra_equality=["tenant_id"])
if not advice.ok:
log.warning("unindexed query\n%s", advice)
no index serves this query
- {status: 1, price: 1, audit.created: -1} filters this query but does not provide the sort
{audit.created: -1}, so MongoDB sorts in memory (it aborts past its blocking-sort memory
limit, 100 MB by default)
suggested index: {status: 1, audit.created: -1, price: 1}
That example is the classic mistake: {status, price, created_at} looks sensible, but the range on
price sits between the equality key and the sort key, so the index stops providing the ordering.
Swapping the last two fixes it, and nothing about the query itself changes.
It also flags the predicates that cannot use an index at all — case-insensitive or unanchored
regex, $ne/$nin, $exists: false — reports covered queries, and understands that a sort on a
field pinned by an equality match is free. extra_equality is for the clauses your own code adds
(a tenant id, a soft-delete flag): they belong at the front of the index, and the advice is wrong
without them.
This is a lint, not a query planner. It reasons about the query shape, not your data
distribution or the real plan cache. explain() remains the only ground truth; this is here to
catch the obvious problems in CI or a dev-mode log, before they reach a database.
Safety
- Keys are matched against the schema, so
$where=...orage__$gt=...never reach the driver. - Values are only ever coerced to the declared scalar type. Nothing is
eval'd or parsed as JSON, so a value of{"$ne": null}stays the harmless nine-character string it is. - Substring search is escaped.
contains/startswith/endswithrun the value throughre.escapeand anchor it, so?name__contains=.*looks for a literal.*. Rawregexis opt-in per field and length-capped. per_pageis clamped tomax_per_page(default 100), so a client cannot ask for the collection.- Projection is whitelisted, so a field marked
projectable=Falsecannot be selected even when it is filterable.
Case-insensitive matching cannot use an ordinary index. case_sensitive=True drops the i option,
which is what makes startswith an index-friendly prefix scan on a large collection.
Errors
All inherit QSMongoError (a ValueError) and carry the offending .param:
UnknownField · UnsupportedOperator · InvalidValue · InvalidPagination · InvalidCursor ·
InvalidProjection
try:
query = parse(request.url.query, schema, cursors=cursors)
except QSMongoError as exc:
raise HTTPException(status_code=400, detail={"parameter": exc.param, "error": str(exc)})
See examples/fastapi_app.py for a complete endpoint.
Edge cases, on purpose
Covered in tests/test_edge_cases.py:
title=black and white— the wordandis a value, not a keyword.title=Dolce%20%26%20Gabbana— an encoded&stays inside the value.title=Dolce & Gabbana— an unencoded&cannot be recovered by any parser, so it raises rather than silently searching forDolce.tag__in=red\,blue,green— a backslash escapes a comma inside a list.title=black+white—+decodes to a space, per form encoding.age=— a blank value on a typed field is an error, not0.
Composing with your own scope
query.filter is a plain dict, so multi-tenant scoping stays yours — the library never invents
clauses on your behalf:
collection.find({"$and": [{"tenant_id": user.tenant_id}, query.filter]})
Or use the aggregation form when you need $lookup afterwards:
collection.aggregate([*query.pipeline(), {"$lookup": {...}}])
Prior art
mongo-queries-manager solves the same problem
and has more features — projection with $elemMatch, $text, custom casters. It takes the
opposite default: every field is queryable unless blacklisted, and types are inferred from the
value (5.6 becomes a float, true becomes a bool).
Use it if you want breadth, or don't want to declare a schema.
Use qsmongo if you want an undeclared field to be an error rather than a silent leak, a numeric
SKU like 01234 to stay the string it is in your documents, and cursor pagination.
fastapi-filter is also whitelist-based via pydantic
models, but is coupled to FastAPI and an ODM; qsmongo takes a string and returns a dict.
Development
pip install -e ".[dev]"
pytest
ruff check .
License
MIT
Metadata
Release files for qsmongo 0.4.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| qsmongo-0.4.0.tar.gz | 30.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| qsmongo-0.4.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 53.0 kB
Release files / qsmongo-0.4.0.tar.gz
| Download URL | qsmongo-0.4.0.tar.gz |
|---|---|
| Size | 30.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
626d0a924c676cdcd4d0bb8188cfe08039f99c03b4859801930bb25ee610c85d
|
|
BLAKE2b-256 checksum How to use checksums |
939f9c230ed08884a094d5a51905abf998a98930735e057d6e7064d2d9456b10
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jul 26, 2026.
Transparency logRelease files / qsmongo-0.4.0-py3-none-any.whl
| Download URL | qsmongo-0.4.0-py3-none-any.whl |
|---|---|
| Size | 22.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
37d0f6b57eed7a705084dc7d4a678fa830dafc80ca952a36e3bfdd46b107ddd9
|
|
BLAKE2b-256 checksum How to use checksums |
05a53bb763baabbbbcc3c1f01bb009f6042ae5daeaa21981b461bf9c716d3e7b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jul 26, 2026.
Transparency log