The dashboard, public HTTP API and Python SDK do not accept or evaluate NQL1 queries, and do not list query fields. Capabilities and limits records this boundary.
A first query
nql1
To change it:
- Add MR. Write
(source.modality:CT OR source.modality:MR) AND source.registered:>=2026-09-01. The parentheses keep the date condition on both, becauseANDbinds beforeOR. UseORrather thanANY(...)here:source.modalityholds one value, and its field definition does not admitANY. - End the window. Write
source.modality:CT AND source.registered:[2026-09-01 TO 2026-09-30]. The upper day is included in full.
What a query is
A query is filter text. Line feeds and carriage returns count as whitespace, so a long query may span several lines. The empty query, or text that is only whitespace, means everything in the authorised, selected scope. The text never runs on its own. It runs inside an envelope that fixes the universe: Scans or Tasks, one project, a version to read, a time zone, the instantnow stands for, which label review states count, and the field registry revision the query was checked against. The same text means the same predicate in every envelope; the envelope decides which records it runs over. The envelope lists every field.
Three separate checks stand between text and results:
Valid syntax promises nothing about a field or its cost.
colour:blue is valid NQL1 whether or not any field called colour exists.
Filters
From here on, the examples mix real field names with short synthetic ones such astags, notes and meta.partNumber. The synthetic names illustrate grammar, and an example is not a promise that a field exists or can be evaluated. Fields used in the examples says which names are real.
A filter, also called a predicate, is one token: a field, an operator and a value with no whitespace between them. Whitespace is allowed only inside the brackets of a range and the parentheses of a set.
Refused query
whitespace_in_predicate. Write class:Crack.
Refused query
expected_colon. NQL1 has no free-text search: every filter names a field.
Equality and comparison
field:value and field:=value are the same equality, and canonical text writes : alone. The comparators are =, !=, >, >=, < and <=, written straight after the colon. A comparator takes one value.
nql1
Refused query
comparator_with_set. To exclude several values, write NOT tags:ANY(crack, porosity).
Ranges
A square bracket includes its bound, a curly brace excludes it, and* leaves a side open. The two sides may mix: {0 TO 1] excludes 0 and includes 1.
nql1
TO needs whitespace on both sides, even next to a quoted bound, so ["a"TO"b"] is refused with invalid_range. Whitespace just inside the brackets is optional, and this range is the same as source.rows:[256 TO 1024]:
nql1
has:field is the way to ask for any value. Both bounds must be the same kind of value: text, number, Boolean, duration or time. A date and a relative date are both time, so they may bound one range, as in source.registered:[2026-09-01 TO now/d}.
Refused query
range_unbounded.
Refused query
range_kind_mismatch.
Existence
has:field selects records where the field has a usable value. false and 0 are usable values; a missing field and a stored null are not. NQL1 does not settle whether an empty list is a usable value. There is no null literal: to find records without a value, write NOT has:field.
nql1
Refused query
null_literal. Write NOT has:meta.partNumber.
Sets
ANY(...) and ALL(...) take one or more scalar values, separated by commas, with no trailing comma. Spaces around the values are allowed. Values keep their order and repeats.
nql1
ANY means the list contains at least one of the values and ALL means it contains every one. Every set value in a query counts toward one shared limit of 32.
Prefix and substring
A prefix is a plain word of at least three characters followed by*. It is the only wildcard: there are no leading, infix or ? wildcards, no regular expressions and no fuzzy matching. The word must itself read as text, so now*, true*, null* and AND* are refused with invalid_prefix, while nowhere* is a prefix.
A substring filter uses ~ and a quoted string, and matches that literal text.
nql1
Refused query
prefix_too_short.
Both forms are part of the language, and a field admits them only if the registry lists them among its operators. No field in the current field registry lists prefix or substring.
Combining filters
AND, OR and NOT combine filters, and parentheses group them. Precedence is NOT, then AND, then OR. Nothing is implied: two filters side by side are refused, so every join is written.
NOT NOT a:1 is refused with unexpected_keyword; double negation needs its own group, NOT (NOT a:1). Keywords need no spaces next to parentheses or quotes, so (a:1)OR(b:2) is valid.
Refused query
missing_operator. Join the filters with AND or OR.
Refused query
keyword_case. Keywords are written in capitals.
Missing values, lists and negation
Four rules decide what happens when a field is absent or holds a list:- A missing value, a null and an empty list satisfy no equality, comparison, range, prefix, set or substring test.
!=is a comparison, so it fails on them too. has:fieldneeds a usable value, as Existence defines it.field:!=valueneeds an existing value and no element equal tovalue.NOT field:valueis the plain opposite offield:value, so it also matches records where the field is missing.
field:!=value and NOT field:value are different trees. NQL1 never rewrites one into the other, and neither should a tool that edits queries.
- List field
- Boolean field
- Number field
A list field
tags on five records:NQL1 fixes that an empty list fails every value test. Whether an empty list counts as a usable value for
has: is not fixed by the language, so do not depend on has:tags to separate [] from an absent field.The != row is the language’s meaning, and the field registry does not admit != on a list field. NOT tags:crack is not a replacement: it also matches records with no tags, so it asks a different question. To require tags and exclude crack, write has:tags AND NOT tags:crack. It agrees with the != row in every column except [], where its answer depends on has:, which is not fixed.Related records
A Scan has related records: its labels, its Source and its Tasks. Flat fields such aslabel.class and label.reviewState are tested separately, so two flat label filters may be satisfied by two different labels. A relation block asks for one related record that satisfies everything inside it.
nql1
The envelope’s label policy here reads accepted and corrected labels. Under the default policy, accepted only, Label A is not read at all and neither query matches.
- There are three relations:
label(...),source(...)andtask(...). The name is followed directly by(. - Inside a block, field names belong to the related record:
label(class:Crack), notlabel(label.class:Crack). - Each block may match a different related record.
label(class:Crack) AND label(class:Porosity)selects Scans with a Crack label and a Porosity label, which may be two labels. - Blocks do not nest, at any depth. A query holds at most 8 blocks.
NOTworks outside and inside a block, with different meanings.NOT label(class:Crack)selects Scans with no Crack label.label(NOT class:Crack)selects Scans with at least one label that is not Crack.- Label fields and
label(...)read only labels whose review state is in the envelope’s label policy. The default policy isacceptedonly;correctedis a separate state and is read only when the policy names it. - A relation word followed by
:is an ordinary field name, solabel:Crackis a flat filter on a field calledlabel.
Refused query
relation_space.
Refused query
nested_relation. Write label(...) AND source(...).
Refused query
unknown_relation. Only label, source and task group filters on one related record.
Field names
A field name is a path of segments. The first segment is an identifier: a letter or underscore, then letters, digits and underscores. After it come dotted identifier segments or bracketed keys. A bracketed key is a JSON string in square brackets and may hold any non-empty text.
The first segment may not be a keyword or
has; later segments may be any identifier, so meta.AND is a valid path. Field names are case-sensitive. Keys use double quotes only.
Refused query
invalid_field. Write meta["machine.name"]:scanner-01.
Values
How an unquoted value is read
An unquoted value runs up to whitespace or one of( ) [ ] { } , ". It is then read in this order, and the first match wins: * alone (an open range side), a trailing * (a prefix), a date, a relative date, a duration, a number, a Boolean, a bare word. A value that is none of these is refused, so quote it.
That order is why text that looks like another kind needs quotes:
A run that starts
now+, now- or now/ must be a valid relative date, so now-ish is refused with invalid_relative_date; write "now-ish".
Strings and escapes
A string is a JSON string: double quotes, JSON escapes (\", \\, \/, \b, \f, \n, \r, \t, \uXXXX) and no raw control characters. Single quotes are not strings. Quoting a bare word changes nothing: class:"Crack" and class:Crack are the same filter.
nql1
Numbers keep their precision
A number is kept as decimal text, never as a floating-point value, so no digit is lost. Canonical form drops trailing fractional zeros and the sign of zero:1.50 is 1.5 and -0.0 is 0.
nql1
measurement:12345678901234567890.1.
Units are not part of a value. The field registry declares a field’s units, and the value is the bare number in those units.
Refused query
invalid_value. Write source.spacing.z:<=0.5 for a field the registry declares in millimetres.
Case and display names
Keywords (AND, OR, NOT, TO, ANY, ALL) are written in capitals. In lower case they are refused with keyword_case wherever they stand, as operators or as values; quote one, as in notes:"and", to search for the word. A lower-case keyword followed by : is a field name, so not:1 is valid.
Field paths, IDs and option IDs are case-sensitive. The parser never trims, folds or normalises text; how text values match is the field registry’s rule, versioned with it.
A display name such as Crack is a text value. Resolving it to a class, option or person ID is the field registry’s job, and happens only when the name is unambiguous; the parser never picks a match.
Dates, durations and time zones
Time in NQL1 comes from the envelope: itstimeZone (default UTC) and its asOf, the pinned instant that now stands for.
- A date-only value is a calendar day in the envelope’s time zone. An inclusive upper day bound includes the whole day, so
[2026-09-01 TO 2026-09-30]ends before the start of 1 October in that zone. Around a clock change a day can last 23 or 25 hours; its duration depends on the zone. nowisasOf.now-7dis exactly seven 24-hour periods earlier, whatever the clocks did. Units are minutes, hours, days and weeks. Calendar months, years and seconds are refused withunsupported_time_unit./dtakes the day. It turns the instant into the calendar day that contains it in the zone, and that day then behaves exactly like a date-only value. The offset is applied first:now-7d/dis the day containing the instant 7 × 24 hours beforeasOf.- Without
/d, a relative date is an instant.
timeZone set to Europe/Berlin and asOf at 2026-10-25T12:00:00.000Z, a day when Berlin clocks go back:
A duration compares with a duration field such as time waited, and a range of durations uses duration bounds:
nql1
nql1
Days that clocks split or skip
Days that clocks split or skip
A day is every instant whose local date is that day. Where the clocks went back over midnight, as in America/Goose_Bay on 7 November 2010, the day is two separate periods that no pair of bounds can express. Resolving it is refused with
calendar_day_not_contiguous, for a date-only value and for a /d day alike.A day the clocks skipped entirely, as in Pacific/Apia on 30 December 2011, is the empty interval at the jump.Because /d is applied after the offset, [now-7d/d TO now/d] usually covers the seven days before today and today. If the clocks changed during the week, the instant 7 × 24 hours back falls at a different time of day than asOf. When asOf is close to midnight, that can move the first day by one, giving seven or nine local days.Supported range of days and zones
Supported range of days and zones
Days from 1583-01-01 to 9999-12-31, and instants within them, can be resolved; the Gregorian calendar is in force everywhere from 1583. An earlier day is refused with
date_out_of_range. A relative date that lands outside the range is refused with relative_date_out_of_range, and an offset longer than the whole range is refused as soon as it is parsed.The envelope’s timeZone is an IANA zone name written exactly as the pinned tz database table spells it (tzdata 2026c), or UTC. Etc/UTC, Etc/GMT, europe/berlin and +02:00 are refused; links such as Asia/Calcutta are accepted as written, so two spellings of one zone are two different envelopes. A zone the evaluating runtime cannot resolve is refused with time_zone_unavailable.Canonical form
Every valid query has one canonical text. Query builders, agents and anyone who stores a query should store and compare canonical text, because two spellings of the same filter produce the same tree and the same canonical text. Canonical text uses single spaces around keywords,, between set values, capital keywords, : for equality, the fewest parentheses precedence allows, and NOT (...) around a group. A text value is written bare when it reads back as the same text and as a JSON string otherwise. Identifier segments are written .segment and other keys ["key"]. An open range side is written [* or *].
Canonical form keeps operand order, duplicate set values and the exact spelling of every text value. Parsing canonical text returns the same tree, and serialising that tree returns the same text.
Limits
Limits apply to each query. A limit refuses the query; it never truncates it, and NQL1 never expands a query into a larger equivalent form.
OR alternatives are summed:
(a:1 OR a:2) AND (b:1 OR b:2) has four. A tree or envelope sent as JSON is also bounded before any schema reads it: Trees sent as data lists those bounds. Execution budgets are separate and belong to the planner.
Refusals
A refused query returns the first refusal only, with a stable code and a sentence a person can act on. A text refusal carries a span: a half-open range of UTF-16 code unit offsets into the text, never past its end. The parser checks text in this order: the raw input ceiling, then syntax, then the limits on the canonical tree, then the length of the canonical text. Within syntax, the first error wins. Each rule above shows its own refusal. Repairable refusals maps the common codes to their fixes, and Refusal codes lists every code with its message. Codes are part of the language version: a code is never renamed or reused for another meaning within NQL1.Versioning
NQL1 is frozen. A change to what any accepted text means is a new language version, never a reinterpretation of a stored query. A stored query records its language version separately from the field registry revision it was checked against. Nitsor refuses an unknown language version rather than guessing. The language has deliberate boundaries. It has no free-text search, nonull, no calendar months or years, no days before 1583, no regular expressions, no fuzzy matching and no leading or infix wildcards. Unquoted values are deliberately narrow, so CT-2.5, 1.2.840.10008 and words that start with a digit are quoted.
Full grammar
Grammar in EBNF
Grammar in EBNF
grammar
Lexical rules
Lexical rules
Next step
Query language for agents
The tree and envelope as JSON, with schemas to download.
Query recipes
Worked queries for common questions, with what each one selects.
| Plans | All plans |
|---|---|
| Last verified | 2026-10-25 |