Predicates and Saved Views
When you want only some records — only featured articles, only members from one department, only events after a date — you write a predicate in a where: field: on a query in queries.yml, to decide which records the query selects, or on a fetch, to narrow a query for one page or section. Predicates are structured YAML objects: no special grammar to learn beyond the field-name → value pattern you already use in frontmatter.
This guide covers the predicate format, the patterns for reusing predicates as saved views, and how foundations build filter UIs that compose predicates from reader interactions.
The simplest predicate
A predicate is a YAML object whose top-level keys are field names. Bare values are matched as equality. Multiple keys are combined with implicit AND:
# pages/blog/page.yml
fetch:
query: articles
where:
featured: true
category: newsThis delivers the query's articles where featured == true AND category == 'news'. On a fetch, a predicate narrows the query: a record must meet the query's own where: and this one, so a fetch never shows a record its query leaves out.
The same predicate works on a query and on any fetch — in section frontmatter, page.yml or site.yml — and against any source: the site's own records, a host's live records, an external query's response.
Operators for non-equality
When you need more than equality, the value becomes a small object naming the operator:
where:
rank: { in: [associate, full] } # value is one of the list
start_year: { gte: 2010 } # ≥ comparison
status: { not_in: [draft, archived] } # value is none of the list
email: { exists: true } # field has a value
title: { starts_with: 'the' } # text, case-insensitiveEvery operator means the same thing wherever the records come from — the site's own compiled records, or a host that answers queries.
Equality, comparison, membership, presence:
| Operator | Meaning | Example |
|---|---|---|
eq | Equal (also implicit when the value is bare) | { status: { eq: 'active' } } |
ne | Not equal | { status: { ne: 'archived' } } |
gt, gte | Greater than / greater than or equal | { year: { gte: 2020 } } |
lt, lte | Less than / less than or equal | { price: { lt: 100 } } |
in | Equal to one of the listed values | { tag: { in: [news, events] } } |
not_in | Equal to none of the listed values | { status: { not_in: [draft, archived] } } |
exists | true: the field has a value — not missing, empty text or an empty list (0 and false are values). false: it has none | { author: { exists: true } } |
Text — plain text, no wildcards, compared regardless of case:
| Operator | Meaning | Example |
|---|---|---|
starts_with | A text that starts with the value | { title: { starts_with: 'the' } } |
ends_with | A text that ends with the value | { file: { ends_with: '.pdf' } } |
contains | On a text, holds the value as a piece of it; on a list, holds an item equal to it | { title: { contains: 'guide' } } · { tags: { contains: news } } |
Values keep their type: '3' does not equal 3, and a number or a boolean is not text.
Lists. A condition on a field that holds a list holds when any member satisfies it: { tags: featured } selects a record whose tags are [featured, sale], { tags: { ne: featured } } selects one that does not have featured, { tags: { not_in: [a, b] } } one that has neither, and a comparison holds when some member satisfies it.
Missing fields. A record with no value for a field satisfies ne, not_in and exists: false, and nothing else — a record with no status is "not archived".
Composition — and, or, not — is covered below. or earns its place across different fields (featured or pinned); alternatives on one field are an in list.
A predicate the language does not contain stops the build with a message naming the problem: an unknown operator, an empty and: or or:, a text operator with empty text. like and nin are retired — write starts_with, ends_with or contains, and not_in.
Dotted field names (tenure.start) descend into a nested record — see Dotted field names below.
A folder branch is scope:, not a predicate
Where a record sits inside the site's folder is its placement — the top of the folder, or 2024/spring inside two folders. A record does not carry it; to read one branch of that folder, give the query a scope:. It holds the named folder and everything below it, and it respects segment boundaries, so a sibling that merely starts with the same letters is not swept in:
queries:
spring:
schema: '@/post'
scope: '2024' # 2024 and 2024/spring — never 2024bscope: works the same wherever the records come from: on a static site the compiled records are filtered by it, and a host that answers queries takes it as the query's own scope. It belongs to the query alone — which branch a query reads decides what the query is — so a fetch: that names the query cannot carry one; the build stops and says where it goes. To read another branch, declare another query. On a parametric page, scope: :dir reads the branch the URL names (see Parametric Pages).
Plain equality on path still selects a single level:
where: { path: '2024' } # only records directly at 2024
where: { path: '' } # only records at the top levelAn earlier spelling, where: { path: { under: '2024' } }, is retired: the build refuses it and names scope: in its message.
Why not starts_with on path?
starts_with compares text, not folders. Against records at '', 2024, 2024/spring, 2024/spring/may, 2024b and 2023:
| condition | what it actually selects |
|---|---|
{ path: { starts_with: '2024/' } } | 2024/spring, 2024/spring/may — misses 2024 itself |
{ path: { starts_with: '2024' } } | those two, 2024 — and 2024b, a different branch |
scope: '2024' | 2024, 2024/spring, 2024/spring/may |
scope: stops at segment boundaries, so a neighbour like 2024b never sneaks in. Use the text operators for text — names, titles, codes. Use scope: for a branch of the folder.
Strings use single or double quotes; numbers and booleans are bare. null matches missing or null fields.
AND, OR, NOT
Top-level keys are combined with implicit AND. For OR or NOT, use explicit composition keys at any nesting level:
where:
# Implicit AND across these keys.
featured: true
department: biology
# Explicit OR for this one.
or:
- { rank: full }
- { tenured: true }
# Negation.
not:
department: emeritusand: is also available explicitly when you need it (rare, since the top level is already AND).
Composition keys can nest:
where:
and:
- { tenured: true }
- or:
- { rank: full }
- { years_in_role: { gte: 10 } }Means: tenured AND (rank is full OR has been in role for 10+ years).
Dotted field names
For nested objects, use dotted paths:
where:
tenure.start: { gte: 2015 }
address.city: OxfordEach dot descends one level. Missing intermediate objects don't error — they just don't match.
When a step reaches a list — a CV's education entries, say — the path descends into each item, and the values it reaches are read as a list: education.degree: PhD selects a record any of whose education entries has that degree.
Saved views
If you find yourself writing the same predicate in multiple places, save it. A saved view is just a regular set of records, where each record holds a where: predicate alongside human-readable metadata:
site/
└── records/
└── view/
├── tenured-biology.yml
├── recent-hires.yml
└── professors-only.ymlEach file is a record:
# records/view/tenured-biology.yml
name: Tenured Biology
description: Tenured members of the Department of Biology.
where:
department: biology
tenured: trueDeclare the query in site.yml like any other:
# site.yml
queries:
members:
schema: '@/member'
views:
schema: '@/view'A foundation reads the views query (e.g., on a Cover section) to populate a dropdown. When the reader picks a view, the foundation filters with that record's where: value — the same language, with the same meaning, as a where: you write on a query.
There's no special framework feature for saved views. They're just records, and where: is just a field on each record. Authors who want curated named populations write them; foundations decide how to surface them.
Queryable surfaces (filter UIs from a foundation)
For sites where readers compose their own predicates by interacting with controls — a department dropdown, a tenure toggle, a year-range slider — the query declares its queryable surface: which fields are filterable, what type each is, and any type-specific metadata (enum options, range bounds):
# site.yml
queries:
members:
schema: '@/member'
queryable:
department:
type: enum
label: Department
options: [biology, physics, chemistry, geology]
rank:
type: enum
label: Rank
options: [assistant, associate, full, professor]
tenured:
type: boolean
label: Tenured
start_year:
type: range
label: Start year
min: 1800
max: 2025You declare what's filterable; the foundation reads the metadata and renders the controls. When the reader picks values, the foundation composes a where-object — exactly the same shape you'd write by hand — and filters the records with it. Nothing on your side changes.
Field types in the starter set:
| Type | Metadata | Foundation typically renders |
|---|---|---|
enum | options: [...] | Dropdown / radio / checkbox-list |
boolean | (none) | Toggle / checkbox |
range | min, max, optional step | Slider / number-range input |
text | optional placeholder | Text input |
Foundations may add richer types as they need them. The framework passes the metadata through unchanged — your queryable: declaration is the contract.
Where evaluation happens
You don't pick. The same where: { department: biology, tenured: true } is evaluated wherever the records come from:
- The site's own records, on a static site — the framework applies the predicate itself, over the file the build generates from the query: at build time for the pages it prerenders, in the browser for anything fetched later.
- An external query — the framework applies it over the records the endpoint returned.
- A host that answers queries — the predicate travels with the query, and the host returns only the matching records. Nothing changes in what you write.
- A backend reached through a foundation transport — the transport decides what it evaluates.
What predicates can't do
The where-object format is deliberately small. It's a way to select records, not a general query language.
These are intentionally out of scope:
- Aggregation.
COUNT,SUM,AVG,GROUP BY. Compute these in your component code (or in the backend) over the records the predicate returned. - Projection. "Give me only the title and excerpt fields." A predicate never changes which fields a record carries. A list carries each record's brief.
- Joins. Matching records of one query against another's. The author embeds the relationship in the data (id references, embedded arrays).
- Subqueries. Predicates can compose with
and/or/notbut can't reference other queries.
If you find yourself reaching for these, the answer is usually: precompute it during the build, or compute it in your component. The predicate's job is to narrow which records arrive — that's it.
Examples
Featured articles, newest first, top three
fetch:
query: articles
where: { tags: featured }
sort: date desc
limit: 3Active products under $100
fetch:
query: products
where:
active: true
price: { lt: 100 }
sort: price ascRecently joined biology faculty
fetch:
query: members
where:
department: biology
start_year: { gte: 2020 }Anything but drafts
fetch:
query: articles
where:
not:
status: draftMembers hired in two specific departments
fetch:
query: members
where:
department: { in: [biology, geology] }Members who completed a doctorate
fetch:
query: members
where:
'education.doctorate.year': { exists: true }What's next
- Queries — everything a query can say, including the complete operator reference and what a list carries.
- Data Fetching — the
fetch:declaration, and how a fetch narrows a query. - Working with Records — records and queries in depth.
- Data Sources — when your where-objects ship over the wire instead of running locally.