Filtering and sorting in templates
Raytha filters content with a small subset of OData's $filter syntax. You write it in the Filter argument of get_content_items, in the ?filter= parameter of a list view, and in the REST API. This page lists everything the engine accepts and what happens to everything it does not.
A first filter
{% assign news = get_content_items(
ContentType="posts",
Filter="category eq 'news' and rank ge 5 and not contains(PrimaryField, 'draft')",
OrderBy="rank desc, CreationTime desc",
PageSize=10) %}
{% for post in news.Items %}
<p>{{ post.PrimaryField | escape }}</p>
{% endfor %}
Raytha parses the expression with Microsoft's OData parser, turns it into a tree and compiles that tree to SQL with every value bound as a parameter. Values never become part of the SQL text. Anything the compiler does not recognise is rejected with an error. It is never passed through.
Syntax
| Element | Supported forms |
|---|---|
| Comparison | field eq 'x', ne, gt, ge, lt, le |
| Logic | and, or, not, parentheses. not binds tightest, then and, then or. Add parentheses when you mix and with or. |
| Text match | contains(field, 'x'), startswith(field, 'x'), endswith(field, 'x') |
| Null check | field eq null, field ne null |
| Constants | A quoted string 'x', a bare number 5 or 4.5, or true and false |
The field name must be on the left and a constant on the right. These are all rejected:
- Comparing a field with another field (
a eq b) or putting the constant first ('x' eq a). - Any function other than the three above:
tolower,length,year,substringand the rest. gt,ge,ltorleagainstnull.in,has,any,all, arithmetic (add,mul) and$options.
Operators and function names are lowercase. EQ and CONTAINS are syntax errors.
Quote string values with single quotes, even for numbers and dates. rank eq '5' and rank eq 5 do the same thing, and Raytha converts the text to the column's type. A value that does not fit the type is an error (see below).
Which fields you can filter on
| Field | Notes |
|---|---|
| Any custom field | By its developer name, exactly as written. Names are case-sensitive. |
PrimaryField | An alias for the content type's primary field. |
Id | Value is the id in short form, long GUID form, or either prefixed with guid_. |
CreationTime, LastModificationTime | UTC timestamps. CreationTime ge '2026-01-01'. |
IsPublished, IsDraft | IsPublished eq 'true'. Template calls already limit get_content_items to published items, so you do not need it there. |
CreatorUser, LastModifierUser, Template and RoutePath exist on items but cannot be used in a filter. In particular you cannot filter on the route path on the server. To list the items under a prefix such as blog/, loop over the items and test item.RoutePath startswith "blog/" in Liquid (see Template recipes).
Rules by field type
| Field type | Comparison | Text match | Empty / not empty |
|---|---|---|---|
| Single line text, Long text, Wysiwyg, Dropdown, Radio, Attachment, Color | Text comparison. eq and ne are case-sensitive. | Case-insensitive. Underscore and percent characters in the value are literal. | f eq '' or f eq null / f ne '' and f ne null |
| Number | Numeric, with two decimal places. The value must be a number. | Matches the stored text. | f eq null / f ne null |
| Date | By calendar date. The value is '2026-01-31' or a date in the organization's date format. | Matches the stored text. | f eq null / f ne null |
| Checkbox | Compare as text: f eq 'true'. For "not ticked" use f ne 'true'. | n/a | n/a |
| Multiple select | f eq 'x' means "has the choice x"; f ne 'x' means "does not". contains(f, 'x') is the same as eq. Both are exact matches on a choice's developer name. gt, lt and the other range operators are rejected. | only contains | contains(f, '[]') / not contains(f, '[]') |
| One-to-one relationship | f eq '<id>' matches the related item by id. Any other value is compared with the related item's primary field text. | On the related item's primary field text. | f eq '' (the related item has no primary field text, or there is no related item) |
| Repeater | Rejected. | Only contains(f, '[]') for "has no rows" (and not for "has rows"). | As left. |
A comparison against a missing value is false, so title eq 'a' and rank gt 3 skip items that have no value. Negations are real negations: title ne 'a' and not contains(title,'a') include items that have no value at all.
The text-matching operators do not look at the value's case or at SQL wildcards. contains(title, '50%') finds the literal text 50%.
Examples
category eq 'news'
category ne 'archive'
rank ge 3 and rank lt 10
featured eq 'true'
published_on ge '2026-01-01' and published_on lt '2027-01-01'
tags eq 'release'
not contains(tags, '[]')
author eq 'Ada Lovelace'
startswith(PrimaryField, 'The ')
(category eq 'news' or category eq 'tips') and featured eq 'true'
summary eq '' or summary eq null
CreationTime ge '2026-06-01'
Sorting
OrderBy is a comma-separated list of field direction, where direction is asc or desc:
PrimaryField asc
rank desc, CreationTime desc
category asc, PrimaryField asc
- Sortable:
PrimaryField,CreationTime,LastModificationTime,CreatorUserandLastModifierUser(by first name), and every custom field except repeaters. Do not sort byTemplateorRoutePath. Numbers sort numerically, dates by calendar date, relationships by the related item's primary field, text as text. - A term with no direction, an unknown field or an unsortable field is skipped without an error.
OrderBy="PrimaryField"therefore does nothing. Always write the direction. - Raytha adds the item id as a last tie-breaker, so pages stay stable when values repeat.
- With no
OrderBy,get_content_itemssorts byCreationTime desc.
Building a filter from user input
Escape a single quote in a value by doubling it: PrimaryField eq 'O''Brien'. When a template puts input into a filter, double the quotes first, and build the string with capture:
{% assign term = QueryParams.q | default: "" | replace: "'", "''" %}
{% capture filter %}contains(PrimaryField, '{{ term }}'){% endcapture %}
{% assign results = get_content_items(ContentType="posts", Filter=filter, PageSize=20) %}
Without the escape, a visitor can close the quote and add their own conditions. They cannot reach SQL, but they can change what the filter means, and a malformed filter takes the whole page down with an HTTP 400. For a number from the URL, force it to a number first, for example {% assign n = QueryParams.min | plus: 0 %}. For today's date use {{ "now" | date: "%Y-%m-%d" }} inside a capture.
Errors
A filter the engine cannot use fails the page. In a template, get_content_items raises it, and a visitor gets HTTP 400 with the message as plain text. The same applies to a bad ?filter= on a list view. The messages are:
| Message | Cause |
|---|---|
| The filter expression could not be parsed: ... | Syntax error, unterminated quote, unknown operator. |
| Unknown filter field 'x'. | No such developer name on this content type. |
| Field 'x' cannot be used in a filter. | A reserved field like RoutePath, or a relationship that cannot be resolved. |
| Field 'x' cannot be used with this operator. | For example gt on a multiple select or repeater. |
| Field 'x' can only be filtered with contains. / ... by empty or not empty. | startswith or endswith on a multiple select, or any text match except contains(f, '[]') on a repeater. |
| Operator 'GreaterThan' cannot be used with null. | Only eq null and ne null exist. |
| Field 'x' expects a numeric value / a date / true or false. | The constant does not fit the column. |
| Unsupported filter expression of kind 'In'. / Unsupported function 'x'. | Operators and functions outside the table at the top. |
| A filter comparison must reference a field. | The constant is on the left, or the left side is a function such as tolower(a). |
| A filter value must be a constant. | Field-to-field comparison. |
| Function 'startswith' requires a non-null value. | startswith(a, null) and the like. |
Gotchas
- Checkbox filters are text filters.
featured eq truebecomes the textTrue, which does not match the storedtrue. Writefeatured eq 'true'. - The engine reads a missing text value as an empty string, so
summary eq nullnever matches a text, dropdown, checkbox or relationship field. Usesummary eq '', or both as in the example above.eq nullworks on number, date and the built-in time fields. - A dropdown stores the choice's developer name, so filter on that, not on the label.
- Numbers are compared as
decimal(18,2), and only plain non-negative numbers such as12or3.5count. A stored negative number, or one with an exponent, reads as empty: it matches nothing, and it sorts as if it had no value. - Filtering the related item's own fields through a relationship is not possible.
author eqonly compares with its id or primary field text.