A Record query asks for a page of the Records of one Schema, or of groups of them, and says, in one ordered list, exactly what each returned row contains. "Appointments per month, with how many are pending and the first three of them" is one query:
{
"groupBy": [{ "id": "g1", "alias": "month", "path": "startsAt", "bucket": "month" }],
"select": [
"month",
{
"kind": "object",
"id": "o1",
"alias": "appointments",
"select": [
{
"kind": "aggregate",
"id": "a1",
"alias": "pendingCount",
"fn": "count",
"filter": { "type": "property", "path": "status", "operator": "eq", "value": "pending" }
},
{
"kind": "records",
"id": "r1",
"alias": "firstThree",
"sort": [{ "orderBy": "startsAt", "order": "asc" }],
"limit": 3,
"select": ["name", "startsAt"]
}
]
}
]
}Each returned row has exactly that shape: { "month": "2026-01-01", "appointments": { "pendingCount": 5, "firstThree": [{ "name": "Checkup", "startsAt": "2026-01-03T10:00:00Z" }, …] } }.
This page explains that model and the choices behind it. For the exact input, output, limits and errors, see Record Queries.
select Is the Output ShapeThe query has two halves. The row half chooses what the rows are: filter, groupBy, sort, limit and offset. Without groupBy, each row is a Record, and these behave as they do when listing Records. They address the Records' own properties. The one exception is sort, which can also name a top-level aggregate's alias when no property has that name. Records with equal sort values are ordered by ID, so paging is stable. The select half chooses what each returned row looks like.
Every key of a returned row comes from a select entry, in the order of the entries, and nothing else is added. The same holds at every level: the keys of a nested object and of a sampled Record come from their own select. There is no implicit id or data: if a row should carry the Record ID, select id. This makes the output predictable enough to be described before the query runs, which is what lets a consumer, such as a table, a Flow or a generated client, know its columns in advance.
Each entry has a stable id, which identifies the entry for whoever edits the query, and an alias, which is its key in the output. They are separate so that renaming an output column does not change which entry it is. Because aliases become keys, they must be unique within one object, and they cannot be integers, which JSON consumers order before every other key. IDs are unique within one select list too, not across the whole query: a path string is shorthand for an entry whose ID is the path, and a sample naturally selects the same paths, such as name, as the query around it.
Entries are distinguished by kind.
createdAt. A path string is shorthand for a field whose ID is the path and whose alias is the path's last segment.count, sum, avg, min or max: the Records linked to each returned Record through a relationship, or the Records of each group of a grouped query.select. See Objects and Samples.select.A kind the query does not know is rejected rather than ignored, and so is an entry where its kind is not allowed, such as an aggregate inside a sample, with an error that names the entry.
An object entry only arranges the output. Its entries are evaluated exactly as if they were selected next to it, for the same Record or group, so moving an entry into an object changes where its value appears and nothing else. Objects let one query return the nested shape a consumer wants, such as a card with a heading and a group of counts, instead of a flat row the consumer has to reshape.
A records entry adds rows of a different kind to the output: it returns some of the Records behind a row. In a query of Records, a sample needs over, the same relationship and direction an aggregate names, and returns the first Records linked to each returned Record. In a grouped query, a sample without over returns the first Records of each group. Either way, the sample has its own filter, sort and limit, like a small Records list, and its select describes one sampled Record: fields, and objects of fields.
A sample is the first limit Records in its sort order, and Records with equal sort values are ordered by ID, so the same data always gives the same sample. It is a preview, not a list to page through: there is no cursor to read the rest of a sample.
A sample reads Records the same way the rest of the query does:
filter checked against the caller's access at every relationship it crosses. When the caller cannot read the related Schema, the whole sample is null, as an aggregate over it is, and nothing about that Schema is checked.A sample cannot hold an aggregate or another sample, because each would multiply the work by the number of sampled Records, and objects may be nested at most 3 deep.
An aggregate names a relationship and a direction. The direction names the side the aggregated Records are on, the same way a relationship filter's fromFilter and toFilter name the side they test. With a relationship Item -> Release, a Release counts its Items with direction: "from", because the Items are the relationship's from-Records. This matters most for a self-referencing relationship, where the queried Schema is on both sides: for Task -> Task named Parent, where the child is the from-Record, to counts a Task's parents and from counts its children. Because both readings are valid there, the direction is always explicit.
Schema views use aggregate entries for their aggregate columns, which are picked by the relationship's name in the table, so the direction is set for you; see How to Add Aggregate Columns to a Schema View.
An aggregate's filter narrows the aggregated Records. "Released items in the release" and "items in the release" are two aggregates over the same relationship, one with a status filter and one without, and so are "hours of the released items" and "hours of all items". The filter belongs to the aggregate, not to the relationship, because it describes which Records are aggregated, which is the same question whether the Records come from a relationship or from a group.
sum, avg, min and max read the property at path of the linked Records, and its type in the linked Schema decides what they can do. Adding up and averaging need a quantity, so sum and avg read numbers and durations. Ordering needs only a comparison, so min and max also read dates. Any other property type is rejected before the query runs, rather than producing a value that means nothing, such as the sum of some text.
The result keeps the property's representation, so a consumer can display it the way it displays the property: a sum of durations is a duration and the latest due date is a date. For an aggregate over no values, count and sum are 0, because nothing counted and nothing added up are both zero. avg, min and max are null, because no value exists to report.
Stored data is not re-validated when a Schema changes, so a number property can still hold the string "7" written before the change. Aggregates read values as filters and sorts do, and a value that is not of the property's type is skipped. One malformed value never fails the query or turns its total into an error.
min and max Return a Stored Valuemin and max compare values the way sorting compares them: durations by elapsed time under the 30-day rule, date-times as instants, and dates as calendar dates. They return the winning Record's stored value unchanged, as the List - Aggregate node does for durations. A comparison can call different values equal: P1M and P30D are the same length. Normalising the result would therefore return a value that no Record holds and lose what the author wrote, including a date-time's [zone]. Among values that compare as equal, the lowest stored text is returned, so the same data always gives the same answer.
A sum or average of durations is a new value, so it needs a written form. Each duration is read as three components: months, days and a time part. The components are summed as exact numbers, so a sum never overflows. Wherever units must be converted, the conversion uses the same rule as duration comparison: one month counts as 30 days, one day as 24 hours. The result is written as an ISO 8601 duration:
P1M plus P30D is P1M30D, not P2M or P60D.PT90M plus PT30M is PT2H.P1W sums to P7D, and P14M to P1Y2M.P1M plus -P5D is P25D, not one month minus five days.PT0S.P1M and PT0S is P15D.The arithmetic is exact to the microsecond. A stored sub-microsecond part is rounded when it is read, so PT0.0000005S counts as zero. Durations that differ only below a microsecond compare as equal, so min and max pick the lowest stored text among them.
An aggregate never reveals more than the caller could read by listing the linked Records themselves:
filter is checked against the caller's access at every relationship it crosses, exactly as a filter on the queried Records is.Two outcomes look similar but mean different things:
null because the Schema is unreadable: the caller cannot read the linked Schema at all, so there is nothing the caller could aggregate. The query does not fail, so a query shared by several roles still returns its other columns. Nothing about the Schema is checked either, not even whether path exists or has a type the function can read, because a different error would reveal the Schema's shape.avg, min and max this can also be null, when no matching Record has a value. The aggregate's filter is evaluated exactly as the same filter on a Records list. A part of it that the caller cannot evaluate, such as a relationship filter that crosses into a Schema the caller cannot read, makes the whole filter match nothing when the filter depends on it, and is ignored under or or not. Either way, the result never depends on data the caller cannot read, so the filter cannot be used to probe it.groupBy changes what one row is. Without it, a row is a Record. With it, a row is a group: the Records that match filter and share the values of the groupBy keys, such as every Item with the status done created in January. There is still one query, and select is still the whole output shape.
A group row has no single Record to read a field from, so a grouped query selects group entries, aggregates and samples of the group, and objects of them. A group entry returns a key of the group. An aggregate without over summarises the Records of the group, with the same functions, types and per-aggregate filter as an aggregate over a relationship. "Appointments per month" and "pending appointments per month" are two aggregates of the same groups, one with a filter. A records entry without over returns the first Records of the group, which is how a chart of months can also show the first appointments of each month.
A key appears in the output only when a group entry selects it, like every other column. The canonical form is { "kind": "group", "groupBy": "<key id>" }, which names the key unambiguously. Because the common case is to return each key under its own name, a path string equal to a key's alias is shorthand for it: select: ["month", …] with groupBy: [{ "alias": "month", "path": "startsAt", "bucket": "month" }]. Any other field is rejected rather than reinterpreted, so a field never silently changes meaning between a grouped and an ungrouped query.
A key groups by the value of one property, read the way filters and sorts read it. Values that compare as equal form one group: P1M and P30D are the same length, and two date-times written in different zones can be the same instant. As with min and max, the key is a value some Record holds, the lowest stored text of the group, rather than a normalised value no Record holds. A value that is unset, or not of the property's type, belongs to the null group, so every matching Record is in exactly one group and the counts add up to the Record count.
Grouping needs values that can be compared for equality, so text, numbers, booleans, dates, durations, and the standard timestamps and author IDs can be keys. Objects and arrays cannot. A relationship is a different question, "group the Items by their Release", which needs the query to cross the relationship: a key with over names the relationship and the side of the linked Records, as an aggregate does, and each linked Record is a group whose key is its ID.
A Record can be linked to many Records, and grouping by a relationship keeps that: a Task that depends on two Tasks is in both of their groups. Grouping by a property puts each Record in exactly one group, so the counts add up to the Record count; grouping by a relationship does not, because answering "how many Tasks depend on each Task" needs a Task counted once per Task it depends on. A Record linked to nothing is in the null group, as an unset property is.
A bucket groups dates by calendar period. A period depends on where you are: an Item created at 23:30 UTC on 31 January was created on 1 February in Melbourne. Date-times are bucketed in the viewer's timezone, the same timezone that date filters use, so a monthly chart agrees with filtering the same month. Without a timezone, UTC is used. A date without time is a calendar date, not an instant, so it is bucketed as written.
filter applies to the Records before grouping. There is no filter on the groups themselves. Groups are ordered by the requested keys and aggregates, such as the largest groups first by their count. Each group's aggregates are already computed when the groups are ordered, so sorting by one costs nothing extra. After the requested keys, the remaining keys break ties, and because the keys identify a group, the order is total and paging over groups is stable. count keeps its meaning of "how many rows the query has, ignoring the page": in a grouped query, the number of groups.
A grouped query groups exactly the Records the caller could list with the same filter. The Schema's role filters and the gated filter choose the Records first, and the groups and their samples are computed from them only. A count never includes a Record the caller cannot read, a sample never shows one, and a group made only of hidden Records does not appear, so the set of groups reveals nothing that listing the Records would not.
A key by relationship applies the same rule to the linked Records: only links to Records the caller can read form groups. A Record whose only links are to hidden Records is in the null group, as if it had no links, so the groups never reveal the ID of a Record the caller cannot read.
Grouping reads every matching Record, not only a page, so its cost grows with the number of matching Records. To keep one query bounded, a grouped query has at most 4 keys, 5 aggregates and 1000 groups per page, or 100 groups per page with samples, and a 10-second statement timeout. min and max never collect all the values of a large group.
Samples add one more pass over the matching Records for each records entry. Beyond that pass, the cost of samples grows with the groups on the page, not with the number of groups.
Aggregates and samples over a relationship are computed only for the Records on the returned page. The page is chosen first, by filter, sort, limit and offset, and the aggregates are computed for those Records afterwards. Records skipped by offset never have their links aggregated, so the cost of a query grows with the page size and the number of links of the Records on it, not with the size of the Schema. Aggregates over the same relationship and direction are computed together in one pass, each with its own filter. Each sample is read separately, and stops after its first limit Records.
Sorting by an aggregate is the exception. The page cannot be chosen until every matching Record's aggregate is known, so the aggregate is also computed for every matching Record first, read the same way. Its cost grows with the matching Records and their links, like a relationship sort. An aggregate still cannot filter the Records. To keep the cost of one query bounded, a query has at most 50 select entries, counting the entries nested in objects and samples. A query with aggregates or samples is limited to 10 aggregates, 5 samples of at most 50 Records each, at most 3 relationship and direction pairs across them, filters nesting at most 3 relationship filters, pages of at most 100 Records, and a 5-second statement timeout.