Skip to main content
NQL1 (Nitsor Query Language 1) is the filter language Nitsor uses to say which Scans or Tasks a query selects. It is a small, strict dialect in the spirit of Lucene, and it is neither Lucene nor SQL. Every rule on this page is the behaviour of Nitsor’s NQL1 parser, which reads query text and either builds a query tree from it or refuses it with a code.
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.
For the query tree, the envelope and machine-readable schemas, read Query language for agents. For worked examples, read Query recipes.

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, because AND binds before OR. Use OR rather than ANY(...) here: source.modality holds one value, and its field definition does not admit ANY.
  • 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 instant now 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 as tags, 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
Refused with whitespace_in_predicate. Write class:Crack.
Refused query
Refused with 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
Refused with 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
At least one side needs a bound; 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
Refused with range_unbounded.
Refused query
Refused with 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
Refused with 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
On a list field, 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
Refused with 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
Refused with missing_operator. Join the filters with AND or OR.
Refused query
Refused with 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:
  1. 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.
  2. has:field needs a usable value, as Existence defines it.
  3. field:!=value needs an existing value and no element equal to value.
  4. NOT field:value is the plain opposite of field: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.
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.
A Scan has related records: its labels, its Source and its Tasks. Flat fields such as label.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

Language illustration

Flat label fields versus one label block

Flat label fields versus one label blockOne Scan holds two labels, read under a label policy of accepted and corrected. Label A has class Crack and review state corrected. Label B has class Porosity and review state accepted. The flat query label.class:Crack AND label.reviewState:accepted matches the Scan, because Label A satisfies the class filter and Label B satisfies the review-state filter. The block query label(class:Crack AND reviewState:accepted) does not match, because no single label satisfies both filters.Scan blade-0261label policy: accepted, correctedLabel Aclass: Crackstate: correctedLabel Bclass: Porositystate: acceptedFlat label fieldslabel.class:Crack ANDlabel.reviewState:acceptedMatch: A has the class, B the stateOne label blocklabel(class:Crack ANDreviewState:accepted)No match: no one label has both

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.

The rules for relation blocks:
  • There are three relations: label(...), source(...) and task(...). The name is followed directly by (.
  • Inside a block, field names belong to the related record: label(class:Crack), not label(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.
  • NOT works 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 is accepted only; corrected is a separate state and is read only when the policy names it.
  • A relation word followed by : is an ordinary field name, so label:Crack is a flat filter on a field called label.
Nitsor does not evaluate relation blocks; Capabilities and limits records this.
Refused query
Refused with relation_space.
Refused query
Refused with nested_relation. Write label(...) AND source(...).
Refused query
Refused with 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
Refused with 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
Its canonical text is 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
Refused with 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: its timeZone (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.
  • now is asOf. now-7d is exactly seven 24-hour periods earlier, whatever the clocks did. Units are minutes, hours, days and weeks. Calendar months, years and seconds are refused with unsupported_time_unit.
  • /d takes 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/d is the day containing the instant 7 × 24 hours before asOf.
  • Without /d, a relative date is an instant.
With 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
A timestamp is quoted text with an explicit offset:
nql1
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.
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, no null, 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
The grammar describes structure. The lexical rules below, and the relation rule that blocks do not nest, narrow it further.

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.
Read a query field explains how a field’s definition limits what you can write.
PlansAll plans
Last verified2026-10-25