Diagnostics & lints
Every construct in SurrealQL has a contract — what the author must mean for the statement to make sense. A diagnostic fires when that contract is provably violated, whether or not SurrealDB would run the query. This is the complete catalog: 90 codes across eight families, plus the parser's own 0xxx codes.
The contract-first model
- Contracts, not engine behavior. Whether SurrealDB throws, silently tolerates (kind-ordered comparisons), or coerces is irrelevant to severity — tolerated misuse is the reason this tool exists. One code per contract; variations of the same violation are message variants, never new codes.
- Inference is never affected. A finding never changes an inferred type.
UPDATE person SET age = 'x'still types aspersonrows; poison (any) appears only where the type is genuinely unknowable. - Statically provable only. Every code is checkable from source + schema. When a value is a host parameter, the check is exported as a constraint instead of emitted as a finding.
Severity vs. level
Every code carries two things. Severity is intrinsic data — E error (provably fails or misbehaves), W warning (provably suspicious but executable), or I hint (analyzer limitation or style note). The default level is the rustc-style policy a consumer applies when no [lints] override names the code:
check, blocks CI
warn reported as a warning — surfaced, non-blocking
allow off by default — opt in per project
The two are independent: a code can be intrinsically an E error and still default to warn (the violation is real, but common enough that failing CI on it out of the box would be hostile), and a W warning can default to allow. Consumers apply policy at their edge: the CLI uses it for exit codes, the LSP for editor severities, host adapters for build failures. In rendered output the code's letter reflects the resolved severity (E/W/I); parse-level findings keep an S prefix.
In-source suppression
A suppression comment is source-authored intent, so it applies at analysis time, rustc-style — the directive covers the next line (or its own line when trailing a statement), and a suppressed finding never leaves the pipeline. It must name a real catalog code; blanket suppressions are rejected, and a directive that violates its own contract (unknown code, or a missing reason where one is required) is 7013.
-- surrealguard: allow(E1001) reason="table created by a migration tool at runtime" SELECT * FROM external_table; SELECT ssn FROM user; -- surrealguard: allow(1002) reason="column added out-of-band"
The directive accepts a code with or without its severity letter (allow(E1001) or allow(1001)). A reason="…" is required for any deny-level code so the waiver is self-documenting.
option<T> and record<A|B> unions through = NONE guards and type::table() discriminants (including along field paths), so a branch sees the narrowed type — and a branch after a diverging one is flagged unreachable (4006), a provably-constant guard folds to a dead branch (4024). It also reads inside PERMISSIONS FOR clauses — undefined fields, undefined fn:: calls, non-boolean results, always-false predicates — and catches comparison footguns: = NONE/= NULL against a kind that excludes it, IN/CONTAINS element-kind mismatches, and equality between record links whose tables can't overlap.
Graph edge-filter WHERE
A graph step may carry an inline predicate that filters the edge (or target) rows during traversal. SurrealGuard supports both spellings and type-checks the predicate against the fields of the filtered table — exactly as it would a top-level WHERE:
-- parenthesised edge filter SELECT ->(likes WHERE strength > 3)->post FROM user; -- bracketed edge filter (equivalent) SELECT ->likes[WHERE strength > 3]->post FROM user;
The predicate is resolved in the scope of the likes edge table, so:
- an unknown field in the filter is 1002 (
WHERE stength > 3→ no fieldstengthonlikes); - a non-boolean predicate is 2005 (
WHERE strengthwherestrengthisint); - a comparison the field's kind can never satisfy is 7005;
- calls and operators inside the filter are checked like anywhere else (5xxx, 2004).
The families
Codes are <family><3 digits>. Jump to a family, or read the full catalog below — every code, its severity, its default level, and what trips it.
Parse-level breakage, emitted by the parser before any analysis runs. These are not in the catalog table — they carry an S prefix and are never demotable, because a source that does not parse cannot be checked.
| Code | Level | Contract & what trips it |
|---|---|---|
| S0000 | always | The source parses at all — a parser failure that produces no tree; the message is the parser's own |
| S0001 | always | No unparsable region in the source — tree-sitter ERROR node — a stray token, a malformed clause, an unterminated literal |
| S0002 | always | No required syntax node is missing — a MISSING node — an unclosed (, an absent keyword the grammar requires |
Every name a query uses must resolve against the schema, wherever the schema constrains it.
| Code | Level | Contract & what trips it |
|---|---|---|
| E1001 | deny | A table reference names a known table — FROM/targets, thin statements, RELATE endpoints, record<t> in DEFINE FIELD, relation IN/OUT tables, DEFINE ... ON table |
| E1002 | deny | A field reference names a declared field of its row's table (schemafull only; FLEXIBLE subtrees exempt) — projections, WHERE/expressions, SET/UNSET targets, payload keys, RETURN, OMIT, FETCH, SPLIT, GROUP/ORDER keys, INSERT columns, index/event fields in DEFINE, const PATCH paths |
| E1012 | deny | A schema-object reference names a known object of that kind — REBUILD/REMOVE INDEX, REMOVE EVENT, SEARCH ANALYZER refs, WITH INDEX hints |
| W1021 | warn | REMOVE removes something that exists — REMOVE TABLE ghost |
| W1022 | warn | A definition does not silently redefine (OVERWRITE states intent) — two DEFINE TABLE person |
| E1023 | warn | FETCH names something that can hold records — FETCH age (int); an alias of a computed non-record value |
| E1024 | warn | SPLIT names a collection field — SPLIT age |
| E1025 | deny | A subfield is declared under an object-shaped parent — FIELD a TYPE int then FIELD a.b |
| E1027 | deny | An index-backed operator has its supporting index — @@/search::* need a SEARCH index; <|k|> needs MTREE/HNSW |
| W1029 | warn | Each index covers a distinct field set — two indexes on (email) |
| E1032 | deny | DEFINE ANALYZER components name known tokenizers/filters/languages — FILTERS snowball(klingon) — tokenizer names are parser-covered (the grammar hard-codes them); filter names/languages emit here |
Values must inhabit the kinds their position demands; operators, casts, and clauses require operands they can act on.
| Code | Level | Contract & what trips it |
|---|---|---|
| E2001 | deny | A value written to a field inhabits the field's declared type — SET values, CONTENT/MERGE/REPLACE payload values, INSERT tuple values and object payloads, DEFAULT and VALUE clauses in DEFINE, record-link targets (record<a> ⊄ record<b>), NONE into non-optional (message points at option<>) |
| E2004 | deny | The operands make sense together for the operator — binary and unary, arithmetic and comparison, compound assignment (age += 'x'); SurrealDB kind-ordering instead of throwing changes nothing |
| W2005 | warn | A condition position expects a boolean — IF conditions, ASSERT clauses, bare non-boolean WHERE |
| E2007 | deny | A cast names a known type — <ghost> x |
| E2008 | deny | A conversion can succeed — kind-proven (<duration> true) or value-proven (<int> 'abc', type::int('x')) — the proof strength varies, the contract doesn't |
| E2012 | deny | A body returns what it declares — fn:: -> string { RETURN 1 }; closures |$x| -> string { RETURN 1 } |
| W2015 | warn | A value-requiring position gets a value that is always present — option<int> field in x + 1 |
| E2017 | deny | ORDER BY keys name fields available on the result rows (or RAND()) — non-field key; explicit projections not containing the key |
| E2018 | deny | LIMIT/START take a non-negative integer — wrong kind (via params; literals parse-rejected), negative constants |
| E2019 | deny | TIMEOUT takes a duration — parser-covered today; emission exists for when params are grammatical |
| E2020 | deny | KILL takes a live-query uuid — parser-covered; grammar-fork bug: KILL $id fails to parse |
| E2021 | deny | SHOW SINCE takes a versionstamp or datetime. |
| E2022 | deny | FOR iterates something iterable — FOR $x IN 42 — ranges must be modeled first or this false-positives |
| E2025 | deny | READONLY fields are written only at creation — UPDATE t SET created = ... |
| W2026 | warn | Computed (VALUE-clause) fields are not hand-assigned — the write is silently overwritten |
| E2030 | deny | Index/filter/splat apply to collections — age[0], name[WHERE ..], age.* |
| E2031 | deny | A regex literal compiles — name ~ 'unclosed(' |
| E2032 | deny | Literal content is valid for its kind — d'2024-13-45', u'not-a-uuid' |
| E2033 | deny | PATCH operations are well-formed — unknown op, path without / |
| E2034 | deny | Required fields are provided at creation — CREATE person; with non-optional, no-DEFAULT name |
| E2035 | deny | DEFINE ANALYZER filter arguments are valid — edgengram(5, 2) |
| E2036 | deny | GeoJSON literals have their declared shape — {type: 'Pointt', ...} |
| E2037 | deny | A field's DEFAULT satisfies its own ASSERT — DEFAULT 'activ' ASSERT $value IN ['active','inactive'] |
A traversal or RELATE must use relations as declared — in->edge->out.
| Code | Level | Contract & what trips it |
|---|---|---|
| E3001 | deny | A step traverses a relation table — ->person-> in a chain; a RELATE edge that is a plain table |
| E3002 | deny | The usage matches the relation's declared shape (in->edge->out) — wrong-direction traversal, a hop landing off the far side, RELATE writing endpoints on the wrong sides — messages show declared vs written shape |
| E3004 | warn | A FROM-position chain is complete (edge->target pairs) — FROM user->writes |
| E3009 | deny | A traversal starts from records — age->writes-> |
| W3011 | warn | Graph recursion is bounded — @{..} with no upper bound |
Clauses must be valid on their statement, and control flow must be well-formed and reachable.
| Code | Level | Contract & what trips it |
|---|---|---|
| E4001 | deny | Clause not valid on this statement — SELECT ... RETURN NONE, CREATE ... WHERE |
| E4002 | deny | SELECT VALUE with multiple projections — SELECT VALUE a, b FROM t — verified: both SurrealDB's parser *and* ours reject the syntax, so this is parse-level (0xxx); the code stays reserved, no analyzer emission |
| E4003 | deny | ONLY on a table-wide target without LIMIT 1 — SELECT * FROM ONLY person, UPDATE ONLY person — deterministic runtime error (SingleOnlyOutput); CREATE is exempt (always one row) |
| E4004 | deny | INSERT tuple column/value count mismatch — (a, b) VALUES (1) |
| E4005 | deny | BREAK/CONTINUE outside a loop — top-level BREAK |
| W4006 | warn | Unreachable statements after RETURN/BREAK/THROW — RETURN 1; SELECT ... in a block |
| E4007 | deny | Transaction pairing contract: BEGIN opens exactly one transaction that COMMIT/CANCEL closes — unopened COMMIT/CANCEL, nested BEGIN, BEGIN never closed |
| E4009 | deny | LIVE SELECT with unsupported clause — LIVE SELECT ... GROUP BY |
| W4010 | warn | Duplicate SET target in one statement — SET age = 1, age = 2 |
| W4011 | warn | Duplicate projection key/alias — SELECT age, age FROM t, two AS x |
| W4012 | warn | OMIT without a wildcard projection — verified parser-covered: the grammar only accepts OMIT alongside * — code reserved, no emission |
| W4013 | warn | GROUP BY field not in projections — SurrealDB aggregate rules |
| I4016 | allow | Empty block — verified unreachable: {} in value position is an empty *object* literal, and statement-position blocks don't have their value consumed — code reserved, no emission |
| W4017 | warn | Block ends with LET — its value is NONE — { LET $x = f(); } consumed as a value |
| W4018 | warn | Side-effecting subquery in read position — SELECT (CREATE log) FROM t |
| W4019 | warn | CREATE/INSERT on a relation table without in/out — CREATE likes SET strength = 1 |
| W4020 | warn | RETURN mode meaningless for the statement — CREATE ... RETURN BEFORE (always NONE) |
| E4021 | warn | SHOW CHANGES on a table without CHANGEFEED. |
| W4022 | warn | SELECT from a DROP table — rows are never retained |
| W4023 | warn | Count() without GROUP BY yields 1 per row, not a total — add GROUP ALL for a total |
| W4024 | warn | An IF branch is unreachable — its guard provably folds to a constant — IF false { ... }, the ELSE after IF true { ... } |
| E4025 | deny | A wildcard projection cannot be aggregated by a GROUP clause — SELECT * FROM t GROUP BY k, SELECT * FROM t GROUP ALL, SELECT *, count() FROM t GROUP BY k — 3.0.5 rejects all of them outright (Incorrect selector for aggregate selection, expression \*\ … cannot be aggregated in a group); 2.x silently drops the *, so the query never returns what its author asked for under either engine |
| W4026 | warn | A filtered ONLY has no provable single-row target — SELECT * FROM ONLY t WHERE status = 'open' — errors (Expected a single result output when using the ONLY keyword) the moment two rows match, but succeeds while one does; W, not E, because the filter may well be single-row for reasons the schema does not state. Silent when at most one row is provable: a record-id target, WHERE id = …, an equality covering every field of a UNIQUE index, or LIMIT 1. Sibling of 4003, which owns the *unfiltered* table-wide case |
Calls must resolve, match their signature, and terminate.
| Code | Level | Contract & what trips it |
|---|---|---|
| E5001 | deny | A call resolves to a function that exists — unknown builtins, undefined fn::, methods not available on the receiver's kind |
| E5002 | deny | A call matches the function's signature — argument count, per-argument kinds (anchored per argument), fn:: declared params, a closure declaring more parameters than its consumer binds |
| E5005 | deny | A const argument satisfies the function's value contract — type::field('aeg') naming no field, non-string paths (type::field(42)), type::thing('ghost', ..) naming no table, out-of-range constants (math::fixed(x, -1)) |
| W5009 | warn | fn:: definitions terminate (no direct/mutual recursion cycles) — fn::f calls fn::f |
| W5010 | warn | Events do not trigger themselves (directly or in a cycle) — event on person THEN mutates person; A→B→A |
A $param in a checkable position produces a constraint host adapters discharge, not a finding. Codes here fire only on contradictions and misuse.
| Code | Level | Contract & what trips it |
|---|---|---|
| E6001 | deny | Conflicting constraints on one param — WHERE $x > 3 AND $x = 'abc' |
| W6002 | warn | Param shadows a DEFINE PARAM with a different kind — LET $min_age = 'x' vs defined int |
| I6003 | allow | Unresolvable dynamic construct (analyzer limitation) — current dynamic(6001) class |
| W6004 | warn | Param used before its LET in source order — RETURN $x; LET $x = 1; |
| E6005 | deny | Context param used outside its context — $before outside an event, $parent outside a subquery |
| E6006 | deny | Host-declared type contradicts query constraint — host binds $age: string, query needs int |
| E6007 | deny | Assignment to a protected parameter — LET $auth = {...} |
Opinions, not contract violations — many ship allow so you opt into the ones your team wants. All configurable via [lints].
| Code | Level | Contract & what trips it |
|---|---|---|
| W7001 | allow | Unused LET binding — LET $x = 1; never read |
| I7002 | allow | LET shadowing — inner LET $x over outer |
| I7003 | allow | Mixed-kind array literal — [1, 'a'] |
| W7004 | warn | Control flow is decided by a constant — IF true, WHERE 1 = 1, FOR $x IN [] |
| W7005 | warn | A comparison against a closed literal set must be able to match — WHEN $event = 'CRATE' — $event is 'CREATE' | 'UPDATE' | 'DELETE'; value-proven always-false (distinct from 2004: the kinds are comparable) |
| W7006 | warn | Empty IN/CONTAINS list — WHERE x IN [] |
| I7007 | warn | SELECT * with explicit fields — SELECT *, age FROM t |
| I7008 | allow | Schemaless table in a typed workspace — queries against fieldless tables |
| W7009 | allow | Whole-table UPDATE/DELETE without WHERE — DELETE person; |
| W7011 | warn | Assignment to id in SET — SET id = ... |
| W7012 | warn | Blocking or side-effecting call in a computed context — http::get(...) / sleep() in a field VALUE or event body |
| W7013 | warn | A suppression directive names a catalog code (with a reason when required) — -- surrealguard: allow(ghost); missing reason under require_suppression_reasons |
| I7014 | allow | Whole-table SELECT without WHERE/LIMIT — SELECT * FROM person; |
| I7015 | allow | Bare SELECT * (over-fetch / schema-drift brittleness) — SELECT * FROM person WHERE id = person:tobie; |
Gated on [analysis] surrealdb_version. Checks resolve against a per-version registry of function availability and syntax support.
| Code | Level | Contract & what trips it |
|---|---|---|
| E8001 | deny | Every function used exists in the configured target version — unavailable (array::fold on 1.x) or renamed (string::endsWith — message suggests string::ends_with) |
| E8003 | deny | Syntax requires a newer version — closures / ?? on old targets |
Parameter constraints
Every use of an unbound $param exports the kind and value domain its uses imply — SET age = $age → $age: int; string::len($s) → $s: string; LIMIT $n → $n: int and n >= 0; type::field($f) → $f: string and one of the table's field paths. Constraints from multiple uses intersect; an empty intersection is 6001. Host adapters (the Rust macro and TS codegen) enforce them at the call site.
Context parameters
SurrealQL injects parameters by context; the analyzer models them as implicitly bound, with kinds where the context defines them — so their bodies type-check and using one outside its context is 6005.
| Param | Context | Kind |
|---|---|---|
$this | any row scope | current row (table's object type) |
$parent | subquery | enclosing row |
$event | DEFINE EVENT | 'CREATE' | 'UPDATE' | 'DELETE' |
$before, $after | DEFINE EVENT | the table's row type |
$value, $input | EVENT / FIELD VALUE·ASSERT | event row / the field's declared type |
$auth, $session, $token, $scope | permissions / anywhere | session-shaped objects (open) |