The workspace holds millions of rows. "Show me payments' failed requests in the last hour, slowest first" is a one-line question - if you can say it in KQL. This lesson is the everyday half of KQL: picking rows and columns.
What you need to know already: 24.1 (workspace, tables, running a query with az monitor log-analytics query), Ch 6 (pipes and quoting in bash), Ch 7 (grep and regular expressions), Ch 8-9 (HTTP status codes: 5xx = server error).
The shape of a query
KQL reads left to right like a bash pipeline (Ch 6): a table, then operators separated by |. Each operator takes a table in and hands a (smaller, or reshaped) table on - where bash pipes pass lines of text, KQL pipes pass rows with named columns.
The example uses AppRequests, one row per HTTP request an app served. Its columns: TimeGenerated (when), AppRoleName (which service), Name (method and path, e.g. POST /api/pay), ResultCode (HTTP status), Success (true/false), DurationMs (how long it took, in milliseconds).
AppRequests
| where TimeGenerated > ago(1h)
| where AppRoleName == "payments-api" and Success == false
| project TimeGenerated, Name, ResultCode, DurationMs
| order by TimeGenerated desc
| take 20
Read it line by line: from AppRequests, keep the last hour, keep payments-api's failed requests, keep only four columns, newest first, show 20.
The operators you will use daily
Each line: the operator, what it does, an example.
where filter rows where ResultCode startswith "5"
project choose / rename / compute cols project TimeGenerated, code = ResultCode
extend add computed columns extend seconds = DurationMs / 1000.0
take any N rows (no order!) take 10 (limit is a synonym)
count one row, one number count
distinct unique combinations distinct AppRoleName, ResultCode
sort by order rows (desc by default) sort by DurationMs desc (order by = same)
top sort + take in one top 5 by DurationMs
getschema columns and types KubeEvents | getschema
take returns arbitrary rows - for "the latest N" you need top N by TimeGenerated or sort by TimeGenerated desc | take N.
Time first
Filter on TimeGenerated first. The workspace stores data in chunks by time (called shards), and a time filter lets the engine skip whole chunks; a query without it scans everything kept (the entire retention, 24.1). Portal and alerts add a time range, but write it yourself anyway.
| where TimeGenerated > ago(30m)
| where TimeGenerated between (datetime(2026-09-24 09:00) .. datetime(2026-09-24 09:30))
| where TimeGenerated > now(-2h)
A literal is a value written straight into the query. Two kinds for time:
- a datetime - a point in time:
datetime(2026-09-24T09:15:00Z)(theZmeans UTC) - a timespan - a length of time:
30s 5m 2h 1d 250ms
now() is the current time, ago(5m) is now() - 5m, and between (a .. b) keeps values from a to b inclusive. Datetime minus datetime is a timespan.
Strings: has, contains, and the case rules
== != exact, case-SENSITIVE
=~ !~ exact, case-insensitive
has !has a whole TERM, case-insensitive, uses the index <- default choice
contains !contains any substring, case-insensitive, scans <- when you must
startswith endswith case-insensitive
in ("a","b") exact list; in~ (...) case-insensitive
has_any ("a","b") any of several terms
matches regex @"..." regular expression
Case-sensitive means "Cart" and "cart" are different.
has splits text into terms (words) at every character that is not a letter or digit, and looks the term up in an index - a lookup list the workspace builds of which rows contain which term, like the index at the back of a book. LogMessage has "upstream" is fast; contains "upstream" scans every row. They differ in results too:
"upstream connect error" has "stream" -> false contains "stream" -> true
"POST /api/payments/refund" has "payments" -> true (terms: POST, api, payments, refund)
Use has unless you really need a substring inside a word.
Strings go in double or single quotes. @"C:\path" is a verbatim string: the backslash is just a character, not an escape - useful for regular expressions (Ch 7): @"duration_ms=(\d+)".
Comparing numbers and empty values
isnotempty(x) keeps rows where column x has a value (it is neither empty nor missing - a missing value is called null).
| where DurationMs between (1000 .. 5000) // inclusive
| where ResultCode in ("502", "503", "504") // ResultCode is a string in AppRequests!
| where isnotempty(PodIp)
ResultCode in AppRequests is a string: ResultCode >= 500 fails with "Cannot compare values of types string and long". Use startswith "5" or toint(ResultCode) >= 500. Check types with getschema when a filter behaves oddly.
let
let app = "payments-api";
let window = 30m;
AppRequests
| where TimeGenerated > ago(window) and AppRoleName == app
| count
let gives a value a name (like a bash variable) for the rest of the query - later it can name a whole table too (24.9). Each let statement ends with ;; the last statement must be the query itself.
Reading an error
ERROR: (BadArgumentError) The request had some invalid properties
Inner error: {
"code": "SemanticError",
"innererror": {
"code": "SEM0100",
"message": "'where' operator: Failed to resolve scalar expression named 'Foo'"
}
}
A scalar expression is anything that gives one value per row - a column name, 1 + 2, ago(1h). So the message says "there is no column called Foo".
SemanticError / SEM0100: a column that does not exist (typo, wrong table, wrong case - column names are case-sensitive). SyntaxError / SYN0002: the parser gave up at the position it names.
Worked: narrowing down, one pipe at a time
ContainerLogV2
| where TimeGenerated > ago(30m) // 1. time
| where PodNamespace == "shop" // 2. the cheap exact filter
| where LogLevel == "error" // 3. another exact filter
| where LogMessage has "upstream" // 4. the indexed term
| project TimeGenerated, PodName, LogMessage // 5. only the columns you read
| take 20
Put the cheapest, most selective filters first, and project early when rows are wide - the engine then moves less data through the rest of the pipe.
Common mistakes, shown
where AppRoleName == "Payments-API" -> 0 rows: == is case-sensitive; use =~
where ResultCode == 500 -> error: ResultCode is a string
where LogMessage has "status=5" -> 0 rows: has matches whole terms ("status", "503")
where TimeGenerated > "2026-09-24" -> compare with datetime(2026-09-24), not a string
| take 10 | where ... -> filters only 10 arbitrary rows
The last one is the sneakiest: operators run in order, so take before where samples first and filters the sample.
What you can now do
- Write a query that filters by time, exact value, term and range, and keeps only the columns you need.
- Pick between
==,=~,hasandcontains, and know whyhasis the default. - Read a SemanticError and find the misspelled or wrongly-typed column.