Querying content
How a template fetches published documents with query() (where, tag, near, orderBy, limit, offset, exclude, organisation, onePer, with, and the terminal methods), nearby(), children(), ancestors(), smartList() and search(), with every rule, limit and error.
A template reads content, never writes it. Besides the page’s own entry and an index page’s items, a template fetches documents with a small set of functions:
| Function | Use it for |
|---|---|
query('type') | A list of published documents of one type, filtered and sorted |
nearby(entry, km, type) | Documents near a document, nearest first |
children(doc), ancestors(doc) | A nesting document’s tree |
smartList('key') | A list an editor maintains in the admin |
search(q) | The site’s search service, with facets and paging |
singletons.<type> | A singleton document (see Globals) |
Every one of them returns only documents that are published in the request locale and shown on the current site. There is no way to read a draft or another site’s hidden content from a template.
{% set featured = query('listing').tag('eat-drink.*').where('dogFriendly', true).orderBy('title').limit(6).all() %}
<ul class="cards">
{% for place in featured %}
<li><a href="{{ place.uri|url }}">{{ place.title }}</a></li>
{% empty %}
<li>{{ t('places.none') }}</li>
{% endfor %}
</ul> query()
query('listing') starts a query over one type the current site serves. Each method returns a new query, so a partial query can be kept and reused:
{% set places = query('listing').tag('eat-drink.*') %}
<p>{{ t('index.count', places.count()) }}</p>
{% for p in places.orderBy('title').limit(12).all() %}...{% endfor %} A query covers one type. Passing several (query(['listing', 'article']) or query('listing', 'article')) returns nothing, with no error at check or render; run one query per type, or use search(), which searches every type, for a mixed list.
The query runs only when a terminal method (all(), first(), count(), exists() or ids()) is called. Each terminal call runs it again, so call all() once and keep the result with {% set %}.
Filtering on fields: index: true
A query can filter and sort only on fields declared index: true in the schema. title can always be filtered and sorted on, and the engine’s own dates (createdAt, updatedAt and publishedAt, on a type that does not declare a field of that name) can be sorted on with orderBy() but not filtered with where(), which keeps nothing. This keeps every query a database index lookup however large the site grows.
fields:
- name: dogFriendly
id: fld_ab12cd34ef
type: boolean
index: true A query that filters or sorts on a field that is not indexed, or not a field of the type, fails the render with an error naming the field:
query().where("website"): website is not declared index: true, so it cannot be queried engine-dmo check does not catch this; render the page (engine-dmo render <path>) to be sure.
Methods
| Method | What it does |
|---|---|
where(field, value) | Keep documents whose field equals the value |
where(field, op, value) | Compare with an operator: eq, ne, in (the value is a list), gt, gte, lt, lte, exists (the value is true or false). Any other operator is an error |
tag(key, ...) | Keep documents carrying every tag given. A key matches that tag only; key.* matches the tag and every tag below it (eat-drink.*). Only public vocabulary tags count |
near(point, km) | Keep documents within km kilometres of a point, and order them nearest first unless orderBy() says otherwise. The point is anything with lat and lon (a geo value, an address’s geo) or a document with a geo field. km defaults to 10 and is capped at 500. The type needs a geo field declared index: true. A point that is empty keeps nothing |
orderBy(field, dir) | Sort by an indexed field, title, createdAt, updatedAt or publishedAt, or by distance after near(). dir is asc (the default) or desc |
limit(n) | At most n documents, at most 500 |
offset(n) | Skip the first n documents |
exclude(doc, ...) | Leave documents out, given as entries or ids: exclude(entry) leaves the current page out |
organisation(x) | Keep the documents one organisation owns. x is the organisation’s id, an entry that has an organisation, or an organisation value |
onePer('organisation') | Keep the first document of each organisation in the query’s order (and every document no organisation owns), so a strip shows one item per business. limit() and count() count what is kept |
with(paths, ...) | Read relations and tree paths for every result up front. See Eager loading |
Terminal methods
| Method | Returns |
|---|---|
all() | The documents as a list of entries. Without limit(), at most 100 |
first() | The first document, or nothing |
count() | How many documents match, exactly, ignoring limit() |
exists() | Whether any document matches |
ids() | The documents’ ids |
Values
Each document is an entry, the same shape as the page’s entry (see The page context): title, uri, url, tags, dates and every field. Relations are read lazily: the first time a template reads a relation on one entry of the list, it is read for all of them in one go, so a loop that prints each item’s related document costs a query or two, not one per item. Each entry also carries organisationId, the id of the organisation that owns it, when one does.
Ordering
Without orderBy(), a query uses the type’s ordering from the schema, or title order when it has none; after near(), nearest first.
Where value types matter
where() compares as the field stores its value: a boolean as true or false, a number as written, a relation by the related document (pass the entry or its id), a select by its option value, a date or datetime field by a date written as text ('2026-09-01' or an RFC 3339 time), anything else as text.
A date value such as today or now is not accepted by where(): the query logs is not a date or an RFC 3339 time and keeps nothing. Pass it through |iso first.
{{ query('listing').where('priceBand', 'in', ['£', '££']).count() }}
{% set next = query('article').where('publishedAt', 'gte', today|iso).orderBy('publishedAt').limit(5).all() %}
{% set inArea = query('listing').where('area', entry).all() %}
{{ query('listing').where('phone', 'exists', true).count() }} Errors
A misuse the author can fix fails the render with a message naming it, and the page answers 500 (under serve --dev, the development error page):
| Message | Cause |
|---|---|
query("x"): "x" is not a type served on site main | The type does not exist or is not on this site |
query().where("f"): f is not a field of listing | No such field |
query().where("f"): f is not declared index: true, so it cannot be queried | The field is not indexed (also for orderBy()) |
query().where(): "like" is not an operator: eq, ne, in, gt, gte, lt, lte or exists | Unknown operator |
near() needs a geo field declared index: true on listing | No indexed geo field |
query().onePer() takes 'organisation' | Any other argument |
A database error does not fail the page: it is logged and the query returns an empty list.
Paging
query() has no paging of its own beyond limit() and offset(). For a paged list of a type, use the type’s index page (a route with index: and paginate:), which gives items and pagination (see An index page). For a paged custom list, use search() with paginate().
Eager loading
with() names the relations and tree paths a template will read on every result, and reads each level for all results before the first renders:
{% set regions = query('area').where('kind', 'region').with(['heroImage', 'children', 'children.heroImage']).all() %}
{% set areas = query('area').with('children', limit=6, orderBy='-title').all() %} - A path is a field (a relation, or a field that holds one) or a tree relation (
children,ancestors,siblings), with each deeper step after a dot. - A call that names one path takes keywords that shape the list each entry ends up with at the path’s last step:
limit=,orderBy=(a field; a leading-sorts descending) anddesc=.
Without with() the same values are still read efficiently, lazily, one group at a time. with() only says up front what the template needs.
nearby()
nearby(entry, km, type) is the documents of a type within km kilometres of an entry’s point, nearest first, with the entry itself left out. type defaults to the entry’s own type and km to 10. It returns at most 12 documents, as a list (no .all()):
{% for p in nearby(entry, 5) %}<a href="{{ p.uri|url }}">{{ p.title }}</a>{% endfor %}
{% for e in nearby(entry, 20, 'event') %}...{% endfor %} The entry’s point is its type’s first indexed geo field, or a field named location, centre or geo. An entry without a point gives an empty list.
children() and ancestors()
On a type whose route nests (nest: true), the page’s own entry already has entry.children, entry.ancestors and entry.parent. The functions give the same for any document value, such as a related one:
{% for c in children(entry.parent) %}<a href="{{ c.uri|url }}">{{ c.title }}</a>{% endfor %}
{% for a in ancestors(entry) %}<a href="{{ a.uri|url }}">{{ a.title }}</a> / {% endfor %} children(doc) is the published children in the request locale, in title order. ancestors(doc) is the ancestors, root first. Both are empty for a type that does not nest on the site. When a document came from a list, the first children() call reads the children of every document in that list at once, so a menu of regions listing each region’s places costs the queries of one region.
smartList()
A smart list is a saved query an editor maintains in the admin. A project can seed one in config/tags/smartlists.yaml; from then on the admin owns it.
{% set dogs = smartList('dog-friendly-places') %}
<h2>{{ t('lists.dogs') }} ({{ dogs.count() }})</h2>
{% for p in dogs.limit(12).all() %}<a href="{{ p.uri|url }}">{{ p.title }}</a>{% endfor %} | Method | Returns |
|---|---|
.limit(n) | The list limited to n documents |
.all() | The documents in the list’s own order. Without a limit, up to 500; a limit above 500 renders 500 and the engine logs it |
.first() | The first document, or nothing |
.count() | How many documents the list holds, exactly |
Only a list marked public and belonging to this site (or to no site) renders. Any other key renders as an empty list, never an error, so a template cannot reveal a list the admin keeps internal.
search()
search(q) runs a query through the site’s search service: the configured backend, its query log and, when the backend is unavailable, the browse fallback over published content.
{% set results = search(request.query.q).types('listing', 'event').paginate(20) %}
{% if results.degraded %}<p>{{ t('search.degraded') }}</p>{% endif %}
<p>{{ t('search.count', results.total) }}</p>
{% for item in results.items %}
<a href="{% if item.clickUrl %}{{ item.clickUrl|url }}{% else %}{{ item.uri|url }}{% endif %}">{{ item.title }}</a>
{% if item.highlight %}<p>{{ item.highlight }}</p>{% endif %}
{% endfor %} | Method | What it does |
|---|---|
.types(key, ...) | Only these types |
.tags(key, ...) | Only documents with these tags |
.near(point, km) | Only documents within km (default 10) of a point; with no words, nearest first |
.sort(order) | relevance, name, distance or recent. Without it, a search with words is by relevance |
.facets(name, ...) | Return only these facets |
.paginate(n) | Run it: the page ?page= names, n per page |
.limit(n) | Run it: page one, n results |
n defaults to 20 and is capped at 100. The result:
| Key | Value |
|---|---|
items | The hits, each id, type, title, summary, uri, url, tags, position, fields (the document’s indexed scalar fields), image (its main image as an image value, or empty), and where the backend gives them highlight (safe markup), distance and clickUrl (a link that records which result was chosen) |
total | How many results |
facets | Per facet name, a list of value, count, label |
degraded | true when the answer came from the browse fallback rather than the search backend |
backend | Which backend answered |
query, sort | What was asked |
page, pages, hasMore | The page, how many pages, whether there are more |
pagination | As an index page’s pagination, its links keeping the rest of the query string |
didYouMean | Always empty at present |
A query shorter than the configured minimum length, the empty one included, is browse mode and is not logged. A search from a template is logged as the search route’s is. The search route itself renders search.html with the same object as results; see Search.
Performance
- Every list’s entries, their relations, tags, dates and translations are read in a few statements per list, however many entries it holds.
- Everything a response renders shares one cache for that response: the layout’s menus, the page’s lists and every entry’s relations read each document once between them.
- To cache an expensive part of a page across requests, use
{% cache %}; see Caching. engine-dmo render <path> --jsonlists the queries a page made.
Globals
The variables every public template can read (site, sites, locale, locales, page, request, now, today, settings, singletons, nav, csrf, assets, devMode) with every key each one holds.
Filters
Every filter a Meridian template can use, grouped (text, locale-aware formatting of numbers, dates and money, rich text, URLs and encoding, lists, and the Pongo2 built-ins), with its argument, default, output and example.