Vigilfield Docs

VFQL ↔ Kusto (KQL) compatibility

VFQL is Vigilfield's query language. It is a subset of Kusto Query Language plus a small number of additions this page names, so a detection written for Microsoft Sentinel or Azure Data Explorer usually runs here unchanged — and where it does not, this page says so by name. The additions run the other way: a query using one will not run in Kusto, and VFQL additions below is the whole list.

This page is the only place Vigilfield makes a compatibility claim. Sales material, the marketing site and the product documentation link here rather than restating it, because a second copy would drift. It is checked against the compiler on every build — see How this page stays true at the end — so a construct cannot gain or lose support without this page moving with it.

KQL-compatible; supports a subset of the Kusto Query Language. Kusto and KQL are trademarks of Microsoft Corporation; Vigilfield is not affiliated with or endorsed by Microsoft.

That line goes with the claim wherever the claim is made — this page, the product documentation, the marketing site, sales material. The claim is never written as an unqualified "supports KQL": what is supported is the subset below.

How to read it

  • supported — accepted, and it means what Kusto means. Under VFQL additions it means accepted, and Kusto has no such construct for it to agree with — that section is the only place the word is read that way, and it says so at the top.
  • partial — accepted, but it differs from Kusto in a way that can change your results. The difference is stated on the row. Read it before you rely on it.
  • unsupported — rejected at compile time, with a diagnostic naming the construct. It never runs, and never silently returns something else.

There is no fourth state, in either direction. Vigilfield does not accept a Kusto construct and quietly give it a different meaning: where an honest translation does not exist, the query is refused. Several rows below are unsupported for exactly that reason and say which — startofweek and percentile are the clearest examples. And an addition is never listed beside the Kusto constructs as though it were one: it goes in VFQL additions, so "supported" on this page never becomes a claim about Kusto that Kusto cannot make.

A name missing from this page entirely is unsupported by omission. The tables aim to cover the Kusto surface a migrating detection actually uses, not all of it.

Tabular operators

Written after a |, in a pipeline.

NameStatusNotes
wheresupported
extendsupported
projectsupported
project-awaysupported
project-renamesupported
summarizesupportedWith and without by. Aggregates are legal only here.
sortsupportedorder by is accepted as the same operator, as in Kusto.
takesupportedlimit is accepted as the same operator, as in Kusto.
topsupportedtop N by <expr>.
distinctsupported
joinpartialFour kind= values — inner, leftouter, rightouter, fullouter. A join written with no kind= at all is rejected; see innerunique below.
unionsupportedBoth spellings: union A, B as a source, and a union B stage. Branches need not share columns — a branch missing one contributes NULL, which is Kusto's kind=outer default. Rows are never deduplicated, as in Kusto. A column that appears in two branches with different types is a compile-time error in Trino rather than Kusto's two type-suffixed columns.
mv-expandpartialOne array column: mv-expand <column> expands it to one row per element, replacing the column with the element under the same name. The column must be one your catalog declares string_array; over anything else the query is refused at compile time, naming the column, and no SQL runs. The element is a string and is no longer an array, so a second mv-expand of it is refused the same way. Three Kusto forms are refused by name and have rows below: to typeof(…) on the operand, several columns in one stage, and the limit N clause. Kusto also expands a dynamic object — a property bag becomes one row per key — and VFQL has array columns and no dynamic values, so that form is the compile-time refusal rather than a row of its own. The order matters and matches Kusto's: a where written before an mv-expand filters the unexpanded rows.
mv-expand to typeofunsupportedThe to typeof(<type>) clause on an mv-expand operand. Kusto converts each element to the declared type and yields null where the conversion fails; Trino's CAST raises an error instead, so the two do not mean the same thing. Expand, then convert with extend.
mv-expand multiple columnsunsupportedmv-expand a, b — Kusto expands several arrays in lockstep, pairing element i of each on one row and padding the shorter ones with null. Two separate mv-expand stages are not the same query: they produce a cross product. Expand one column per query.
mv-expand limitunsupportedThe limit N clause of an mv-expand, which caps the elements taken per input row. SQL's LIMIT caps the whole result, so reading one as the other would return a different row set.
make-seriespartialOne row per bucket, empty buckets included: make-series <aggregations> on <axis> from <start> to <end> step <width>. Every bucket in the range comes back; one with no rows carries 0, which is Kusto's own fill. ⚠️ The shape differs from Kusto's and this is the divergence to know about. Kusto returns one dynamic array column per aggregation, one array per series, and nothing in this lakehouse holds a dynamic value — so what you get is the row-per-bucket table summarize <agg> by bin(<time>, <span>) gives, with the gaps filled in. Four Kusto forms are refused by name and have rows below: default=, in range(…), a missing from or to, and the by clause. ⚠️ Buckets are anchored on the step's own boundaries — multiples of the step from the Unix epoch — not on from. Kusto walks from from; this walks the same boundaries summarize … by bin(<time>, <span>) produces, because the two halves of the operator are one join and a series anchored anywhere else would match no aggregated row at all. So from datetime(2026-01-01 00:30:00) … step 1h returns buckets at 00:00, 01:00, …, and the first one covers the half hour before from. A range of literal datetime(…) bounds and a literal step is counted at compile time and refused past 10,000 buckets; a range written with ago(…), now() or a query parameter has no value until the query runs, so nothing counts it and the engine's own sequence() limit is what you meet. The series_* family reads Kusto's arrays and stays unsupported.
make-series defaultunsupportedThe default=<value> clause on a make-series aggregation. The fill is 0, which is Kusto's own default for every aggregation, so every query VFQL accepts here fills the way Kusto fills; the clause exists to say something else. Vigilfield runs no SQL in any test suite, so a different fill value is the one thing about this operator nothing here could check — a refusal you can read beats a value nobody has verified.
make-series in rangeunsupportedin range(<from>, <to>, <step>), Kusto's other spelling of the same three values. Whether its <to> is inclusive is not answerable from anything Vigilfield vendors, and publishing a boundary we had guessed at is the one thing this page must not do. Write from <start> to <end> step <width>, which pins the same range with no such question.
make-series without from and tounsupportedA make-series that names only a step. Kusto infers the missing bound from the data; inferring it here means a second pass over the same rows to find the axis min/max, so the operator would silently cost twice the scan it looks like — and the bucket count would stop being answerable before the query runs. State both bounds.
make-series byunsupportedThe by <keys> clause, which produces one series per group. Gap-filling per group needs the generated buckets crossed with the distinct key tuples before the join — a second pass over the aggregate and a third relation in the statement. One series is what this implements; dropping the keys silently would answer a different question.
parsepartialExtracts several columns from one text value in one stage: parse <text> with 'prefix' Name 'middle' Other, where * skips any text and a pattern that begins with * matches anywhere rather than from the start. Kusto's simple mode only — kind= and a capture's : <type> are refused by name, and so is parse-where; all three have rows below. Every capture is text; convert it afterwards with toint, todouble or todatetime. A row the pattern does not match keeps its place with every capture null, which is what Kusto's parse does and is why parse-where, which drops the row instead, is refused. ⚠️ Vigilfield runs no SQL in any test suite, so nothing automated checks the value a row comes back with — what is checked is the SQL that is generated.
parse-whereunsupportedKusto's parse-where drops every row its pattern does not match, where parse keeps the row with null captures. Reading one as the other would return rows Kusto excludes. Write parse, then a where on one of the captured columns.
evaluateunsupportedThe plugin surface — evaluate bag_unpack, evaluate autocluster, and the rest.
renderunsupportedVisualization is Vigilfield's, not the query's.
invokeunsupportedNeeds stored functions, which Vigilfield has no catalog for.
lookupunsupportedWrite the equivalent join.
mv-applyunsupportedNeeds per-element subquery application — a whole sub-pipeline evaluated once per expanded element. That is a different mechanism from mv-expand's unnest and it is what is missing; the dynamic-values answer this row used to give stopped being the blocker once the compiler learned to recognise an array column.
searchunsupportedCross-table free-text search. Name the table and use where.
findunsupportedAs search.
printunsupportedEvery query reads a table.
datatableunsupportedInline literal tables.
rangeunsupportedGenerated row sequences.
serializepartialVFQL's serialize names the order: serialize by <column> [asc or desc] [nulls first or last], the same ordering clause sort by takes — including Kusto's unwritten default of desc. Kusto's spelling takes no ordering at all: it fixes whatever physical row order the result set already has, and a SQL engine has none to fix, so an OVER () with no ORDER BY would return a different answer on a different query plan. Every form with no by is refused by name; see serialize without by. It declares an order and does not order the rows you get back — write sort by for that, as you would in SQL. row_number, prev and next are refused unless a serialize precedes them in the same pipeline; a sort by does not count, though Kusto's does. A serialize does not reach into a join's right-hand side or an in (…) subquery, which are separate row sets.
serialize without byunsupportedEvery Kusto serialize that names no ordering keys — the bare operator, and the serialize Name = <expr> assignment form. Both rest on Kusto's physical row order, which has no SQL equivalent; reading them as an arbitrary order would make the result depend on the engine's plan. Write serialize by <column>, and a separate extend for any assignments.
getschemaunsupportedA table's columns are in the Vigilfield catalog UI and API.
sampleunsupported
sample-distinctunsupported
top-nestedunsupported
scanunsupportedSequential state machines over rows.
partitionunsupported
forkunsupportedOne query produces one result set.
facetunsupportedAs fork.
asunsupportedNaming an intermediate result. Use let.
consumeunsupported
reduceunsupported
externaldataunsupportedReads a URI as a table. Vigilfield queries only tables in your own catalog — a deliberate refusal, not a gap.
materializeunsupportedA caching hint on a let.

Kusto's count operator — T piped into a bare count, with no parentheses — is not a stage here. Write summarize count() instead; the count aggregate is supported and has its own row below.

Query-level constructs

NameStatusNotes
letsupportedBoth a scalar value and a tabular pipeline. A let bound to a pipeline is inlined, not cached.
declare query_parameterssupportedTypes bool, datetime, decimal, guid, int, long, real, string, timespan. A parameter's declared type is what its value is bound as, which is what stops a bound value from changing the query's shape.
dynamicunsupportedDynamic (JSON-valued) literals, and dynamic as a parameter type. VFQL v1 has no dynamic values — which is also why split, make_list, make_set and parse_json are unsupported. Two names are not on that list any more: mv-expand reads an array column, which the catalog declares, rather than a dynamic value; and make-series returns a row per bucket instead of Kusto's array per series, which is the partial its own row states.
toscalarunsupportedCollapsing a pipeline to a single value inside an expression.
index expressionunsupportedIndexing into a value — d["key"], a[0]. Reach into a json column with a dotted path instead, below.
inneruniqueunsupportedkind=innerunique, and a join written with no kind= at all, which is what Kusto's default means. innerunique deduplicates the left side and inner does not, so reading a bare join as inner would give a Kusto construct a different meaning. Write the kind= you want.
parse kind=unsupportedkind=simple, kind=regex and kind=relaxed are three different pattern languages, and the flags= that may follow changes the match again. VFQL implements the reading Kusto applies when the clause is absent, which is simple; write parse with no kind=.
parse column typeunsupportedThe : <type> on a capture, as in parse Text with 'code=' Code: int. Kusto converts the captured text and yields null where the conversion fails; Trino's CAST raises an error instead, and the declared type also narrows what Kusto's pattern will match. Capture as text and convert afterwards.
col.field.subfieldpartialA dotted path reaches into a json column, and the extracted value is a string: where Payload.status == 200 will not match, and where Payload.status == "200" will. Wrap it in toint, tolong or todouble to compare as a number. Each segment must be a plain identifier — a segment carrying a . or a quote is rejected rather than spliced into a path that would mean something else — and the first segment must be a column your catalog declares as json.

VFQL additions

The constructs in this section are Vigilfield's own. Kusto does not have them, so a query using one will not run in Sentinel or Azure Data Explorer — the opposite direction from every other table on this page. supported here means "accepted, and this is what it does", because there is no Kusto meaning for it to agree with.

Both halves of that are ADR-0058 §2: additions are permitted, and giving a Kusto construct a different meaning is not. Keeping them in their own section is what lets the second rule stay checkable.

NameStatusNotes
asofsupportedReads a table as it was at a past instant — asof datetime(2026-07-01 10:00:00 UTC) — compiling to Iceberg time travel. It attaches to a catalog table reference and must be written directly after one, so writing it after where or summarize is refused rather than quietly applied somewhere else; writing it on a let-bound name is refused too, because a query-local pipeline has no stored version to read. The operand is datetime(…) or a query parameter declared datetime, and a datetime parameter cannot carry a default, so a saved query parameterised on the instant needs a value on every run. A datetime with no time zone is read as UTC; a named zone other than UTC is refused rather than guessed at. ago(…) and now() are not accepted in this position yet. ⚠️ Refused on a source a scheduled rule reads its rows from (rh-ns305n): a scheduled run already scans that reference from where its last run finished, so an asof pinning it to a third version cannot also hold, and silently discarding either clause would ship wrong rows under a query that looks correct. It still compiles on a join's right-hand side and inside an in (…) reference set, which carry no such scan, and on every ad-hoc query. The row stays supported because the construct is: what is refused is the combination.
scheduled delta windowssupportedNot written by you: the per-table scope of a scheduled rule compiles to the data files the window's append snapshots added — one FOR VERSION AS OF pinned read per snapshot, "$path"-pruned to its files, UNION ALL-ed; an empty window reads nothing (the window is commit history, not a data column; vfql.md has the model). A run whose bookmark is older than retention falls back to the EXCEPT ALL difference of two FOR TIMESTAMP AS OF pinned reads; those reads also carry a vf_ingestion_timestamp interval (from − 1h, to] — a pruning bound, not the window, identical on both operands. Results are unchanged under one premise: a row's commit instant trails its stamp (stamped at extraction, committed when its batch lands) by at most the one-hour margin, so every difference row falls inside the interval in both reads. The residual, stated plainly and confined to the fallback: a row stamped more than the margin before its commit falls outside the interval in both reads and the difference cannot recover it. Deletions are not surfaced — the window shows what the newer version adds and modifies, which is the correct shape for append-only log tables.

Operators

NameStatusNotes
arithmetic operatorssupported+, -, *, /, %.
comparison operatorssupported==, !=, <, <=, >, >=.
logical operatorssupportedand, or, not.
=~supportedCase-insensitive string equality, by folding both sides. !~ is its negation.
haspartialA regular-expression word-boundary match, not Kusto's term index. Kusto tokenizes on ingestion and has asks whether a term is in that index; Vigilfield matches the term between word boundaries against the value. The two agree on ordinary alphanumeric terms and disagree on how a term containing punctuation is split — Cmd has "foo-bar" matches the whole hyphenated string here, where Kusto sees two terms. Your term is escaped before it becomes a pattern, so a term containing . or a vertical bar matches itself rather than changing the pattern. !has is its negation and shares every caveat.
containssupportedCase-insensitive substring, as in Kusto. !contains is its negation.
startswithsupportedCase-insensitive prefix. !startswith is its negation.
endswithsupportedCase-insensitive suffix. !endswith is its negation.
matches regexsupportedCase-sensitive, RE2 syntax, and the pattern is yours — nothing is escaped.
insupportedA literal list, or a subquery. !in is its negation.
has_anysupportedA term list. Each term carries the has caveat above.
betweensupportedbetween (a .. b), inclusive at both ends.
has_allunsupportedWrite the and of two has.
hasprefixunsupportedTerm-index prefix match. startswith is the substring equivalent.
hassuffixunsupportedAs hasprefix; endswith is the substring equivalent.
has_csunsupportedThe case-sensitive term operators.
contains_csunsupportedUse matches regex.
startswith_csunsupportedUse matches regex.
endswith_csunsupportedUse matches regex.

Scalar functions

NameStatusNotes
agosupportedEvaluated by the query engine at run time, not baked in when the query is compiled.
nowsupportedAs ago.
datetimesupportedA datetime literal.
binpartialOne arithmetic path for every span: the value is floored to a multiple of the span, measured from the Unix epoch. That is what Kusto does for a span in seconds, minutes, hours or days, so ordinary bin(t, 1h) agrees. It differs for a calendar-aware bucket — there is no month or year span here — and it means a daily bucket starts at UTC midnight, whatever time zone your session or dashboard displays. Bucket by the boundary you want, not the one you see rendered.
startofdaysupportedUTC, as bin.
startofhoursupportedUTC, as bin.
iffsupported
casesupportedcase(p1, v1, …, otherwise).
isnullsupported
isnotnullsupported
isemptysupported
coalescesupported
strcatsupported
tolowersupported
touppersupported
strlensupported
extractsupportedextract(regex, captureGroup, text), RE2 syntax.
row_numberpartialThe row number in the order a preceding serialize fixed — refused with a diagnostic naming the missing stage if there is none, where Kusto also accepts the order a sort left. Kusto's optional arguments are not accepted: row_number(StartingIndex) starts the count somewhere other than 1, and row_number(StartingIndex, Restart) restarts it per group. Neither has a plan-free translation, so both are rejected rather than approximated.
prevpartialThe value of an expression in the previous row of the serialize order — the same serialize rule as row_number. Only prev(<expr>): Kusto's prev(<expr>, offset) and prev(<expr>, offset, default) are rejected, because accepting the offset alone would leave anyone who wrote all three with their default value read as something else. The offset is 1 and the value in the first row is null.
nextpartialThe following row instead of the previous one; everything on prev applies unchanged, and the value in the last row is null.
tointsupportedA cast. Also what turns a JSON path into a number — see col.field.subfield.
tolongsupportedA cast, as toint.
todoublesupportedA cast, as toint.
tostringsupportedA cast.
todatetimesupportedA cast.
startofweekunsupportedKusto's week starts on Sunday; the engine underneath Vigilfield uses the ISO week, which starts on Monday. The two would agree on six days in seven and disagree silently on the seventh, so this is refused rather than shipped wrong. Use bin(t, 7d) if an epoch-aligned seven-day bucket is what you actually need.
substringunsupportedKusto's start index is 0-based and may be negative, counting back from the end; the engine's is 1-based and reads a negative start differently again. A shift-by-one translation would be correct for exactly the non-negative half.
format_datetimeunsupportedKusto's format specifiers are not the engine's, so passing the string through would render a different date.
splitunsupportedReturns a dynamic array.
extract_allunsupportedReturns an array of every match, and VFQL has no expression type that can hold one — array columns exist, array-valued expressions do not. extract covers the single-capture case: extract(regex, captureGroup, text) returns one match as text.
iifunsupportedKusto's alias for iff. Write iff.
parse_jsonunsupportedNeeds dynamic values. A json column is read with a dotted path instead.
todynamicunsupportedAs parse_json.
array_lengthunsupportedNot implemented — the name is not in VFQL's function table. Its old reason is no longer true: over an array column it would be cardinality, returning a bigint, and needs no dynamic value. What it could never be is Kusto's: Kusto takes any dynamic expression, and VFQL has array columns but no array-valued expression, so array_length(split(s, ',')) has no meaning here. Anything shipped would be a strict subset and would be published partial.
bag_keysunsupportedNeeds dynamic values.
packunsupportedNeeds dynamic values.
pack_arrayunsupportedNeeds dynamic values.
set_unionunsupportedNeeds dynamic values.
set_intersectunsupportedNeeds dynamic values.
set_differenceunsupportedNeeds dynamic values.
strcat_delimunsupportedWrite the strcat with the separator spelled out.
replace_stringunsupported
replace_regexunsupported
trimunsupported
trim_startunsupported
trim_endunsupported
indexofunsupported
countofunsupported
parse_urlunsupported
parse_urlqueryunsupported
base64_encode_tostringunsupported
base64_decode_tostringunsupported
hashunsupported
hash_sha256unsupported
ipv4_is_matchunsupportedThere are no IP-aware functions yet. Compare the string, or match with matches regex.
ipv4_is_privateunsupportedAs ipv4_is_match.
ipv4_compareunsupportedAs ipv4_is_match.
ipv6_is_matchunsupportedAs ipv4_is_match.
geo_info_from_ip_addressunsupported
series_decomposeunsupportedThe series_* family reads Kusto's dynamic array columns, one per series. make-series is implemented here as a row per bucket, so there is no array for these to read; that is the shape difference its row states, not a missing operator.
series_decompose_anomaliesunsupportedAs series_decompose.
datetime_diffunsupportedSubtracting two datetimes.
datetime_addunsupportedAdding a timespan.
startofmonthunsupportedOnly startofday and startofhour exist.
startofyearunsupportedAs startofmonth.
endofdayunsupportedThere is no endof* family.
endofweekunsupportedAs endofday.
endofmonthunsupportedAs endofday.
endofyearunsupportedAs endofday.
dayofweekunsupportedThere are no datetime-part functions yet.
dayofmonthunsupportedAs dayofweek.
dayofyearunsupportedAs dayofweek.
hourofdayunsupportedAs dayofweek.
getmonthunsupportedAs dayofweek.
getyearunsupportedAs dayofweek.
monthofyearunsupportedAs dayofweek.
weekofyearunsupportedAs dayofweek.
gettypeunsupported
toboolunsupportedOnly toint, tolong, todouble, tostring and todatetime exist.
torealunsupportedKusto's alias for todouble. Write todouble.
todecimalunsupportedAs tobool.
totimespanunsupportedAs tobool.
toguidunsupportedAs tobool.
column_ifexistsunsupportedA query names the columns it reads.

Aggregation functions

Legal in summarize, and only there.

NameStatusNotes
countsupported
countifsupported
sumsupported
sumifsupported
avgsupported
minsupported
maxsupported
dcountpartialApproximate. It becomes the engine's approx_distinct, which trades exactness for the ability to run over a large table at all. Expect a small relative error, and do not use it where an exact count is the finding. Kusto's dcount is approximate by default too, but the two use different estimators and will not agree digit for digit. There is no exact-count alternative here today.
arg_maxpartialThe star form does not work. arg_max(Timestamp, *) — the spelling most Kusto users write, meaning "the whole row at the maximum" — is rejected. Name the columns you want: arg_max(Timestamp, Account, Cmd). The output column of arg_max(t, c) is also named arg_max_c, where Kusto names it c.
arg_minpartialThe star form does not work, exactly as for arg_max; name the columns. The output column of arg_min(t, c) is named arg_min_c.
make_listunsupportedBuilds a dynamic array.
make_setunsupportedBuilds a dynamic array.
percentileunsupportedKusto takes a percentile in 0–100 and the engine underneath takes a fraction in 0–1 — and beyond that rescale the two pick different values, nearest-rank against interpolation. It would be wrong by a row rather than absent, so it is refused.
percentilesunsupportedAs percentile.
dcountifunsupported
stdevunsupported
varianceunsupported
anyunsupported
anyifunsupported
take_anyunsupported
make_bagunsupportedBuilds a dynamic value.
bin_autounsupportedNeeds a session-level bin size.

Grammar versions

Every query carries the grammar version it was written against, as vfql_version on the request. Vigilfield currently writes 2 and accepts 1 and 2; a version it does not accept is refused outright rather than read under a different grammar.

Both are accepted because 2 is a conservative extension of 1: everything it adds was rejected by 1, and nothing 1 accepted means something else now. So a query saved before this page grew its newer rows still runs, still means what it meant, and still compiles to the same SQL — you do not have to rewrite or re-save anything.

That is also the rule for what happens next. A version stays accepted only while the current grammar extends it conservatively; a change that would alter what an already-written query means removes the older version from the accepted list instead, and every stored query carrying it is then refused with a message naming the versions that are accepted. You will be told, per query, rather than finding out from a different result.

How this page stays true

Every name the compiler knows about — each function and aggregate it accepts, each construct it explicitly refuses — must have a row here whose status matches what the code does. A test in the compiler crate reads this file and the compiler's own tables and compares them, so landing a function without a row here, or marking something supported that the compiler refuses, fails the build.

That check runs in one direction only. A row for a Kusto name Vigilfield has never heard of — most of the unsupported rows above — is expected, and is not compared against anything, because the compiler holds no table of Kusto's full vocabulary to compare it to. Those rows are maintained by hand. What the test does enforce about them is that they are well-formed: a name, one of the three statuses, a note wherever the status is partial, and no name listed twice.

⚠️ Every Markdown table row in this file is a compatibility row, and its first cell is a single backticked name. That is what lets the test tell a row it must check from a heading it must skip — a table of any other shape added here will fail it.