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
stringhas a null value:int(null),long(null),real(null),datetime(null),timespan(null),bool(null), anddynamic(null). - A field that is absent from a record reads as null.
- A
stringis never null — an absent string reads as"", andisnull("")isfalse. Useisempty()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):
| Comparison | Result | Example |
|---|---|---|
null vs concrete value, == | false | int(null) == 4 → false |
null vs concrete value, != | true | int(null) != 4 → true |
| null vs null | null | int(null) == int(null) → null |
| ordering vs null | null | int(null) < 4 → null |
Two consequences worth memorizing:
where i != 5keeps rows whereiis null — null-vs-value inequality istrue, not null.x == int(null)is not a null check. It yieldsfalseagainst any concrete value and null against null — nevertrue. Useisnull(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:
| Expression | Result |
|---|---|
not(null) | null |
null and false | false |
null and true | null |
null or true | true |
null or false | null |
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.
| Expression | Berserk | Microsoft Kusto | Why |
|---|---|---|---|
dynamic(4) == 4 | true | true | carries a number, values equal |
dynamic(2.0) == 2 | true | true | free numeric widening (2.0 == 2) |
dynamic("a") == "a" | true | true | carries a string, values equal |
dynamic("4") == 4 | false | true | a string is not a number — we never parse |
dynamic(2.5) == 2 | false | true (!) | ADX converts — tolong(2.5) truncates to 2; we compare values |
dynamic(4) > 3 | true | rejected at bind | ordering unwraps the same way (Berserk extension) |
dynamic(4) == dynamic(4) | true | rejected at bind | both sides unwrap (Berserk extension) |
dynamic({"a":1}) == anything | false | — | bags 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 datetimeasT(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:
| Input | asint(x) | toint(x) |
|---|---|---|
dynamic(4) | 4 | 4 |
dynamic("4") | null | 4 (parses) |
dynamic(2.5) | null | 2 (truncates) |
dynamic(null) | null | null |
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
| Expression | Result |
|---|---|
int(null) == 4 / != 4 | false / true |
int(null) == int(null) | null |
int(null) < 4 | null |
not(null) / null and false / null or true | null / false / true |
where <null> | row dropped |
field == "" on an absent field | true |
dynamic(4) == 4 / dynamic("4") == 4 | true / false |
asint(dynamic("4")) / toint(dynamic("4")) | null / 4 |
| null check | isnull(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.