Skip to content

$filter ​

The $filter query option restricts the set of returned entities. The filter expression is parsed into an AST and translated to SQL WHERE clauses by the resolver.

Syntax ​

GET /odata/Products?$filter=price gt 10
GET /odata/Products?$filter=name eq 'Widget'

Comparison operators ​

OperatorMeaningExample
eqEqual$filter=name eq 'Widget'
neNot equal$filter=name ne 'Widget'
gtGreater than$filter=price gt 10
geGreater than or equal$filter=price ge 10
ltLess than$filter=price lt 100
leLess than or equal$filter=price le 100
inIn list$filter=status in ('active','pending')

Logical operators ​

OperatorMeaningExample
andLogical AND$filter=price gt 10 and active eq true
orLogical OR$filter=origin eq 'lhr' or origin eq 'jfk'
notLogical NOT$filter=not contains(name,'test')

String functions ​

FunctionSQL mappingExample
contains(prop, 'val')LIKE '%val%'$filter=contains(name,'Widget')
startswith(prop, 'val')LIKE 'val%'$filter=startswith(name,'Wid')
endswith(prop, 'val')LIKE '%val'$filter=endswith(name,'get')

Case-insensitive matching ​

tolower() and toupper() may wrap either argument of the three functions above. The wrapper is stripped before the LIKE is built, because the default MySQL and SQLite collations already match case-insensitively:

$filter=contains(tolower(name),'widget')   → WHERE name LIKE '%widget%'

On a case-sensitive collation this is a no-op rather than a fold — the comparison follows the column's collation, not the function. Outside these three functions, tolower()/toupper() are not translated.

Lambda operators ​

any and all filter a collection by a predicate on its related entities. Eloquent-backed sets only — see Coverage by resolver below.

GET /odata/Flights?$filter=passengers/any(p:p/name eq 'Alice')
GET /odata/Flights?$filter=passengers/all(p:p/checkedIn eq true)
OperatorMeaningEloquent translation
anyat least one related entity matcheswhereHas()
allevery related entity matcheswhereDoesntHave() with the negated predicate

all is expressed as "no related entity fails the predicate", which is also how it treats an empty collection: a Flight with no passengers satisfies passengers/all(...).

Null handling ​

$filter=description eq null       → WHERE description IS NULL
$filter=description ne null       → WHERE description IS NOT NULL

Precedence and grouping ​

Use parentheses to control precedence:

$filter=(price gt 10 and price lt 100) or name eq 'Special'

Without parentheses, and binds tighter than or.

Coverage by resolver ​

The parser accepts the full OData filter grammar. The two built-in translators implement a subset of it, and they do not implement the same subset:

ConstructFilterToEloquent (discoverModel)FilterToQuery (SQL / AbstractEntitySet)
eq ne gt ge lt leyesyes
and or notyesyes
inyesyes
null comparisonyesyes
contains startswith endswithyesyes
tolower toupper (wrapping the above)yesyes
any / allyesno
Arithmetic (add sub mul div mod)nono
Date/time functions (year(), month(), …)nono
has (enum flags)nono
Geo-spatial functionsnono

Custom resolvers receive the parsed AST and may implement any of it — see Custom Resolvers.

An untranslated construct is dropped, not rejected

A filter the translator does not implement currently contributes no condition at all. The request is accepted, answers 200 OK, and returns the collection as if the predicate had not been sent:

GET /odata/Orders?$filter=year(created_at) eq 2026   → every order, not just 2026's

Never rely on $filter to scope a read to what a caller may see. Scope the entity set itself — a custom entity set whose query() applies the restriction, or a read authorizer — and treat $filter as what the client asks for, never as what the server enforces.

This is tracked as a defect: the translators should answer 501 Not Implemented for constructs they cannot translate.