Berserk Docs

Nulls, Dynamics, and Coercion

How null values behave in comparisons and filters, how dynamic values compare against typed values, and how the asXXX extract-or-null family feeds typed functions

Observability data is full of absent fields and dynamically-typed values: in permissive mode every field resolves from $raw as dynamic, and a field that isn't present in a record reads as null. This page is the single reference for what that means in comparisons, filters, and typed function calls.

The rules here are deliberately aligned with Microsoft Kusto wherever alignment is free, and deliberately different where Microsoft's behavior silently converts data. Every divergence is called out explicitly.

What can be null

  • Every scalar type except string has a null value: int(null), long(null), real(null), datetime(null), timespan(null), bool(null), and dynamic(null).
  • A field that is absent from a record reads as null.
  • A string is never null — an absent string reads as "", and isnull("") is false. Use isempty() for strings. See string.

Test for null with isnull() / isnotnull() — never with ==. As the next section shows, x == int(null) can never be true.

Comparison semantics: the three tiers

Null comparisons follow Microsoft Kusto's rules, which are deliberately uneven (verified against the ADX help cluster):

ComparisonResultExample
null vs concrete value, ==falseint(null) == 4false
null vs concrete value, !=trueint(null) != 4true
null vs nullnullint(null) == int(null) → null
ordering vs nullnullint(null) < 4 → null

Two consequences worth memorizing:

  • where i != 5 keeps rows where i is null — null-vs-value inequality is true, not null.
  • x == int(null) is not a null check. It yields false against any concrete value and null against null — never true. Use isnull(x).

The string exception: in a string context, null counts as the empty string. field == "" matches rows where the field is absent, and field != "" does not — matching Microsoft Kusto.

Three-valued logic in filters

A comparison can therefore produce true, false, or null — and not() / and / or follow three-valued (Kleene) logic:

ExpressionResult
not(null)null
null and falsefalse
null and truenull
null or truetrue
null or falsenull

A where keeps a row only when the predicate is definitively true — both false and null filter the row out. The null survives inside the expression until it meets a connective, which is what makes negation behave correctly:

| where not(latency > 5s)

drops rows where latency is null: null > 5s is null, and not(null) is still null. If you mean "not above threshold, or unknown", say so explicitly:

| where not(latency > 5s) or isnull(latency)

iff() and case() treat a null condition as not-matching: iff(int(null) == int(null), "t", "f") returns "f".

Dynamic values in comparisons

When a dynamic value meets a typed value in a comparison, Berserk compares the stored native value — the dynamic unwraps to whatever it actually carries, and the comparison proceeds with the numeric family's free widening. Extraction only: a value is never parsed or converted across type families to make a comparison succeed.

ExpressionBerserkMicrosoft KustoWhy
dynamic(4) == 4truetruecarries a number, values equal
dynamic(2.0) == 2truetruefree numeric widening (2.0 == 2)
dynamic("a") == "a"truetruecarries a string, values equal
dynamic("4") == 4falsetruea string is not a number — we never parse
dynamic(2.5) == 2falsetrue (!)ADX convertstolong(2.5) truncates to 2; we compare values
dynamic(4) > 3truerejected at bindordering unwraps the same way (Berserk extension)
dynamic(4) == dynamic(4)truerejected at bindboth sides unwrap (Berserk extension)
dynamic({"a":1}) == anythingfalsebags and arrays don't unwrap

The two bold rows are deliberate divergences from Microsoft Kusto. ADX coerces the dynamic toward the typed operand using the converting to*() semantics — which parses strings into numbers and silently truncates 2.5 to 2. Berserk refuses both: if the stored value isn't the same kind of thing, it isn't equal. This keeps where attrs.status == 500 honest — it matches records where status is the number 500, not the string "500". If your data mixes representations, normalize explicitly with tolong() / tostring() in a projection.

The null tiers compose with unwrapping: dynamic("") == int(null) is true (an empty string equals an absent value, exactly like a stored "" field), and dynamic(null) == int(null) is null (both-null).

The asXXX family: extract-or-null for typed arguments

Comparisons unwrap; typed function arguments extract. When a dynamic field is passed to a function or operator that expects a concrete type, Berserk injects the matching extractor — asstring, aslong, asint, asdouble, asbool, asdatetime, astimespan, or asnumeric:

| extend host = toupper(resource.host.name)   // asstring extracts the string
| summarize avg(attributes.duration_ms)       // asnumeric extracts the number
| extend bucket = bin(attributes.ts, 5m)      // asdatetime extracts the datetime

asT(v) extracts, it never converts: if v is a T (or a dynamic carrying one) it yields that value; otherwise it yields null. asstring(dynamic(42)) is null, not "42"; asdatetime(dynamic("2026-01-01")) is null — the stored value is a string, not a datetime. Contrast with the converting to*() family, which parses and reformats:

Inputasint(x)toint(x)
dynamic(4)44
dynamic("4")null4 (parses)
dynamic(2.5)null2 (truncates)
dynamic(null)nullnull

The design rule across this whole page: everything free happens automatically; everything that would materialize a new value requires an explicit to*(). Unwrapping a dynamic and widening 2.0 to compare with 2 are free — they re-read what is already stored. Parsing "4" into 4 creates a value that was never in your data, so it only happens when you write toint() yourself.

Why this matters operationally: bare comparisons on dynamic fields keep the indexes engaged (bloom, shard, and range pruning all work on stored values). Wrapping a scan predicate in tostring() / tolong() forces per-row evaluation and disables pruning — and after this design, it is also unnecessary.

Quick reference

ExpressionResult
int(null) == 4 / != 4false / true
int(null) == int(null)null
int(null) < 4null
not(null) / null and false / null or truenull / false / true
where <null>row dropped
field == "" on an absent fieldtrue
dynamic(4) == 4 / dynamic("4") == 4true / false
asint(dynamic("4")) / toint(dynamic("4"))null / 4
null checkisnull(x) — never == int(null)

See also: Compared to Microsoft KQL for the divergence-focused view, dynamic and string for the type details, and $raw field resolution for how fields become dynamic in the first place.

On this page