In this module
1.5 Null Handling — The Silent Query Killer
Figure 1.5 — Operational workflow from input through documented output.
Null Handling — The Silent Query Killer
Introduction
Figure — Null Handling — The Silent Query Killer. Applied to security investigation workflows at NE.
Null values in KQL cause more incorrect investigation conclusions than any other single issue. A query that silently excludes rows because of nulls can make you believe an attacker did not access a mailbox, did not create a forwarding rule, or did not authenticate from an external IP — when in fact the evidence exists but your query missed it because a comparison field was null.
This subsection teaches you to identify, handle, and defend against null-related query failures.
What null means in KQL
print isempty("") // true — empty string
print isempty(null) // true — null
print isnull("") // false — empty string is not null
print isnull(null) // true — null is null
print isnotempty("") // false
print isnotempty(null) // false
print isnotnull("") // true — empty string is not null
print isnotnull(null) // false// ALL of these return false:
print null == "hello"
print null != "hello" // yes, even != returns false
print null == null // even null compared to itself
print null > 0
print null < 100// You want all sign-ins NOT from the UK
SigninLogs
| where tostring(LocationDetails.countryOrRegion) != "GB"SigninLogs
| extend Country = tostring(LocationDetails.countryOrRegion)
| where Country != "GB" or isempty(Country)// When filtering for "not X", always include nulls
| where FieldName != "value" or isempty(FieldName)// Replace null with a meaningful default
| extend Country = coalesce(
tostring(LocationDetails.countryOrRegion),
"No location data")// Only process rows where the field has data
| where isnotempty(tostring(LocationDetails.countryOrRegion))
| extend Country = tostring(LocationDetails.countryOrRegion)// Explicitly check what has null values and why
SigninLogs
| where TimeGenerated > ago(24h)
| extend Country = tostring(LocationDetails.countryOrRegion)
| summarize
WithLocation = countif(isnotempty(Country)),
WithoutLocation = countif(isempty(Country))// inner join: null keys NEVER match — rows with null are excluded from both sides
// leftouter join: null keys on the left are preserved (with null for right columns)
// leftanti join: null keys on the left ARE included (they have no match on the right)// Instead of inner join (loses null-IP rows):
SigninLogs
| where TimeGenerated > ago(24h)
| join kind=inner (Watchlist) on IPAddress
// Use leftouter join (preserves null-IP rows):
SigninLogs
| where TimeGenerated > ago(24h)
| join kind=leftouter (Watchlist) on IPAddress
| extend WatchlistMatch = isnotempty(WatchlistColumn)// Version 1 (null-blind): misses sign-ins with no location data
SigninLogs
| where TimeGenerated > ago(1h)
| where tostring(LocationDetails.countryOrRegion) != "GB"
// Version 2 (null-aware): catches sign-ins with no location AND non-GB
SigninLogs
| where TimeGenerated > ago(1h)
| extend Country = tostring(LocationDetails.countryOrRegion)
| where Country != "GB" or isempty(Country)
| extend LocationStatus = iff(isempty(Country), "No location data", Country)// count() counts all rows, including those with null values
// dcount(Column) counts distinct non-null values
// countif(condition) counts rows where condition is true — null fails the condition
// sum(), avg(), min(), max() all ignore null values
// If ALL values are null, these return null (not 0)| summarize
DistinctIPs = dcount(IPAddress),
NullIPCount = countif(isempty(IPAddress)),
TotalEvents = count()print 5 + int(null) // → null
print strcat("hello", "") // → "hello" (empty string, not null)
print strcat("hello", tostring(null)) // → "hello" (null converts to empty string in strcat)
print strlen(tostring(null)) // → 0 (null becomes empty string)SigninLogs
| where TimeGenerated > ago(24h)
| extend SessionDuration = datetime_diff('minute', TimeGenerated, CreatedDateTime)
// If CreatedDateTime is null for some rows, SessionDuration is null for those rows
// A subsequent where SessionDuration > 30 silently drops the null rows| extend SessionDuration = datetime_diff('minute', TimeGenerated,
coalesce(CreatedDateTime, TimeGenerated))
// If CreatedDateTime is null, use TimeGenerated as fallback → SessionDuration = 0print isnull(int(null)) // true — the value is null
print isempty("") // true — the string is empty
print isempty(tostring(null)) // true — null converts to empty string, which is empty
print isnull("") // false — empty string is NOT null
print isnotempty("") // false — empty string is empty
print isnotempty("hello") // trueSigninLogs
| where TimeGenerated > ago(24h)
| summarize
TotalRows = count(),
NullIP = countif(isempty(IPAddress)),
NullLocation = countif(isempty(tostring(LocationDetails.countryOrRegion))),
NullUA = countif(isempty(UserAgent)),
NullDevice = countif(isempty(tostring(DeviceDetail.operatingSystem))),
NullRisk = countif(isempty(RiskLevelDuringSignIn)),
NullCAP = countif(array_length(ConditionalAccessPolicies) == 0)
| extend
PctNullIP = round(100.0 * NullIP / TotalRows, 1),
PctNullLocation = round(100.0 * NullLocation / TotalRows, 1),
PctNullUA = round(100.0 * NullUA / TotalRows, 1),
PctNullDevice = round(100.0 * NullDevice / TotalRows, 1),
PctNullRisk = round(100.0 * NullRisk / TotalRows, 1),
PctNullCAP = round(100.0 * NullCAP / TotalRows, 1)| extend Country = coalesce(
tostring(LocationDetails.countryOrRegion),
tostring(LocationDetails.state),
"Unknown"
)| extend IPType = case(
isempty(IPAddress), "No IP recorded",
ipv4_is_in_range(IPAddress, "10.0.0.0/8"), "Corporate",
ipv4_is_in_range(IPAddress, "172.16.0.0/12"), "Corporate",
ipv4_is_in_range(IPAddress, "192.168.0.0/16"), "Corporate",
"External"
)SigninLogs
| where TimeGenerated > ago(7d)
| summarize
Total = count(),
NullIP = countif(isempty(IPAddress)),
NullLocation = countif(isempty(
tostring(LocationDetails.countryOrRegion))),
NullUserAgent = countif(isempty(UserAgent)),
NullAppName = countif(isempty(AppDisplayName))
| extend
PctNullIP = round(100.0 * NullIP / Total, 1),
PctNullLocation = round(100.0 * NullLocation / Total, 1),
PctNullUserAgent = round(100.0 * NullUserAgent / Total, 1),
PctNullAppName = round(100.0 * NullAppName / Total, 1)Try it yourself
Run this null audit against your SigninLogs:
Run this null audit against your SigninLogs:
Any field with more than 5% null values is a field where where filters will silently exclude a significant portion of your data. Note these fields — every query you write against SigninLogs should account for their null rate.
Check your understanding
1. During an investigation, you run: SigninLogs | where tostring(LocationDetails.countryOrRegion) != "GB" and get zero results. Can you conclude that all sign-ins came from the UK?
The myth: Reading the Microsoft KQL documentation is sufficient to learn this concept. The documentation explains the syntax — what more do you need?
The reality: Documentation teaches SYNTAX. This course teaches APPLICATION. The KQL docs show how summarize works with generic examples. This course shows how summarize reveals a password spray pattern hiding in 45,000 sign-in events. The difference between knowing the syntax and knowing WHEN and WHY to use it in a security context is the difference between an analyst who copies queries and one who writes them. Every operator in this course is taught through a security investigation scenario — not abstract data manipulation.
Troubleshooting
"The query returns an error I do not understand." KQL error messages reference the specific line and operator that failed. Read the error message from left to right: it names the operator, the expected input type, and the actual input type. Most errors are type mismatches (passing a string where a datetime is expected) or field name typos. The getschema operator shows every field name and type for any table: TableName | getschema.
"The query runs but returns unexpected results." Add | take 10 after each operator in the pipeline and examine the intermediate output. This reveals WHERE the data transforms in a way you did not expect. Debug the pipeline stage by stage, not the entire query at once.
You are writing a KQL query and cannot remember the exact operator syntax. Do you check the Microsoft documentation or ask Claude?
Check the documentation first for SYNTAX questions (exact parameter names, return types, operator behavior). The KQL documentation at learn.microsoft.com/kusto is the authoritative source. Claude may generate plausible but incorrect syntax — especially for newer operators or recently changed behavior. Use Claude for DESIGN questions: 'I need to detect accounts with sign-ins from more than 3 countries in 24 hours — what is the best KQL approach?' Claude excels at designing the query strategy; the documentation excels at providing the exact syntax.
You've learned how KQL processes data.
K0 gave you the query language's place in the Microsoft security stack. K1 took you through the semantics — tables, operators, the pipe model, and why KQL isn't SQL. Now you write the queries that find what attackers hope you miss.
- 12 modules of query craft — filtering and shaping, joins and unions, time-series analysis, summarisation, string manipulation, and geospatial analysis
- 68 KQL exercises — every one with a realistic dataset, a reference solution, and a discussion of alternative approaches
- K11 — Threat Hunting with KQL — the course's flagship module. Hypothesis-driven methodology, MITRE ATT&CK-aligned hunting across 7 techniques, UEBA composite risk scoring, and retroactive IOC sweeps
- K13 Capstone — The Hunting Lab — three complete investigation scenarios requiring every query skill from the course
- Hunt management and ROI metrics — the operating model that justifies KQL hunt programs to leadership