Skip to content

Every lookup you can put after __ in a Sillo filter (comparison, membership, text matching, null checks, date parts and the JSON-specific set) with the SQL each produces.

await Post.filter(views__gte=100)
await Post.filter(title__icontains="async")
await Post.filter(status__in=["published", "featured"])

Everything after __ is either a lookup or a relation to traverse. With no lookup, the comparison is equality.

LookupSQL
(none)= ?
not<> ?
gt> ?
gte>= ?
lt< ?
lte<= ?
await Post.filter(views__gt=100)
await Post.filter(created_at__gte=cutoff)
await Post.filter(status__not="archived")

status__not="archived" and .exclude(status="archived") differ on NULL: <> 'archived' is NULL (and therefore not true) for a row whose status is NULL, so neither returns it, but only exclude reads as intent. Prefer exclude for negation and keep not for a single inline condition.

LookupSQL
inIN (…)
not_inNOT IN (…)
await Post.filter(id__in=[1, 2, 3])
await Post.filter(status__not_in=["archived", "deleted"])

An empty list is IN () (always false) which is usually right but worth knowing when the list comes from user input.

Large IN lists get slow; past a few thousand ids, a join against a temporary table or a subquery is faster.

await Post.filter(created_at__range=(start, end))

BETWEEN, and inclusive at both ends. For dates that is often not what you want. range=(jan_1, feb_1) includes February the 1st at midnight. Use two bounds when the upper one should be exclusive:

await Post.filter(created_at__gte=jan_1, created_at__lt=feb_1)
LookupSQL
isnull=TrueIS NULL
isnull=FalseIS NOT NULL
not_isnull=TrueIS NOT NULL
await Post.filter(published_at__isnull=True)
await Post.filter(deleted_at__isnull=True) # the soft-delete filter

= None is not the same thing. SQL’s = NULL is never true; IS NULL is the only way to ask.

LookupMatchesCase
containsanywheresensitive
icontainsanywhereinsensitive
startswithat the startsensitive
istartswithat the startinsensitive
endswithat the endsensitive
iendswithat the endinsensitive
iexactwhole valueinsensitive
searchfull-textbackend-dependent
await Post.filter(title__icontains="async")
await Post.filter(slug__startswith="2026-")
await Post.filter(email__iexact="ADA@example.com")

search maps to the backend’s full-text support where there is one and degrades elsewhere. Check what it compiles to on your database with .sql() before relying on it.

LookupCase
posix_regexsensitive
iposix_regexinsensitive
await Post.filter(slug__posix_regex=r"^\d{4}-\d{2}-")

POSIX regular expressions, so PostgreSQL and MySQL. Not supported on SQLite without a registered function. Never indexable.

Available on datetime and date columns:

year, quarter, month, week, day, hour, minute, second, microsecond.

await Post.filter(created_at__year=2026)
await Post.filter(created_at__month=8)
await Post.filter(created_at__year=2026, created_at__quarter=3)

JSONField has its own set, not the ones above:

LookupMeaning
filterMatch by key path
containsThe document contains this structure
contained_byThe document is contained by this structure
isnull / not_isnullThe column is null
await Post.filter(metadata__filter={"theme": "dark"})
await Post.filter(metadata__contains={"tags": ["python"]})

Support varies sharply by backend. JSONB on PostgreSQL is fully queryable, SQLite stores JSON as text and can do much less. Test against the database you deploy on.

If you find yourself filtering the same key repeatedly, that key wants to be a column.

Lookups compose with traversal, to any depth:

await Post.filter(author__name__icontains="ada")
await Post.filter(author__profile__country__in=["GB", "IE"])
await Comment.filter(post__created_at__gte=cutoff)

Each __ before the final lookup is a join.

await Post.filter(tags__name__in=["python", "async"]) # duplicates
await Post.filter(tags__name__in=["python", "async"]).distinct()

A join across a many-to-many yields one row per match, so a post with both tags appears twice. distinct() collapses them.

from tortoise.functions import Count
await (
Post.annotate(comment_count=Count("comments"))
.filter(comment_count__gte=10)
)

A filter on an annotated name becomes HAVING rather than WHERE, which is what lets it see the aggregate. See Aggregation.

The i-prefixed lookups are explicit. Everything else depends on your database’s collation:

  • PostgreSQL is case-sensitive by default. iexact and friends use LOWER() or ILIKE.
  • MySQL is usually case-insensitive by default (utf8mb4_general_ci), so exact and iexact behave identically, and the same code behaves differently on PostgreSQL.
  • SQLite is case-sensitive except for ASCII with NOCASE.

If your development database and your production database differ here, write the i lookup explicitly wherever you mean it. The bug is otherwise invisible until deployment.