Observability Azure

KQL for Azure Monitor and Log Analytics: From Joins to Time-Series, Without Blowing the Budget

You open Log Analytics to answer one question — “which endpoint started throwing 500s at 14:30?” — and forty minutes later you are still fighting the query, not the incident. The join returned a tenth of the rows you expected. The time chart is spiky in a way that looks like an outage but is really gap-free-versus-gappy buckets. The summarize is aggregating on a column you meant to group by. And the Stats pane says you scanned 380 GB to answer a question that lived in the last fifteen minutes. Kusto Query Language (KQL) — the read-only, pipeline-based query language behind Azure Monitor, Log Analytics, Microsoft Sentinel, Azure Resource Graph, and Application Insights — is easy to write and easy to write wrongly, and the two failure modes are correctness (the answer is subtly false) and cost (the answer is right but you paid for a full-retention table scan to get it).

This is the working tour of the language that actually matters when you run production observability on Azure. Not a syntax dump — the operators, in the order a real query flows: filter and shape rows (where, project, extend), collapse them (summarize, bin, make-series), correlate across tables (join with its six kinds, union, lookup), tear apart semi-structured payloads (parse, parse_json, mv-expand, mv-apply), reason over time (series_decompose_anomalies, series_decompose_forecast), package logic for reuse (let, stored functions, materialize), and steer the engine when the default plan is wrong (hint.strategy=shuffle, hint.strategy=broadcast). Woven through every section are the two things a senior engineer internalises that a beginner does not: order is performance (the columnstore rewards a TimeGenerated filter first and a narrow project early), and the default is often not what you want (the default join is innerunique, not relational inner, and it silently changes your counts).

By the end you will write KQL that is both correct and cheap: you will know which join kind matches the relational semantics you meant, when bin() lies and make-series … default=0 tells the truth, how mv-expand multiplies rows and where to put it, why materialize() turns three scans into one, and how the same query drops into a scheduled query rule to page someone. Because this is a reference you will return to mid-incident and mid-optimization, the operators, the join matrix, the string functions, the performance rules, the workspace plans and the alert knobs are all laid out as scannable tables — read the prose once, then keep the tables open.

What problem this solves

Every Azure workload emits telemetry, and almost all of it lands in a Log Analytics workspace as tables you query with KQL. The alert that pages you, the dashboard your director watches, the Sentinel detection that catches an attacker, the Usage query that explains a doubled bill — all of it is KQL. Get the language wrong and the failure is quiet: no exception, no red banner, just a chart that misleads, a join that undercounts, an alert that never fires because the query returned zero rows for the wrong reason, or a monthly bill that climbs because a saved query scans a month to answer a question about an hour.

What breaks without real KQL fluency is trust and money. Trust, because a dashboard built on contains scans and default innerunique joins produces numbers that don’t reconcile with reality, and once an on-call engineer stops believing the dashboard they stop looking at it. Money, because Log Analytics is billed per GB ingested and Basic Logs is billed per GB scanned at query time — an unbounded union * or a contains on a hot table is not just slow, it is a line item. And correctness under pressure, because the queries that matter most run during incidents, when a subtly wrong join or a bin-not-make-series baseline sends you chasing the wrong instance for twenty minutes.

Who hits this: platform and SRE teams querying Azure Monitor daily; security analysts writing Sentinel detections and hunting queries; anyone who owns a Log Analytics bill; developers reading Application Insights requests/exceptions/dependencies. It bites hardest on high-cardinality data (per-request OperationId, per-user IDs), cross-table correlation (request → exception → dependency), semi-structured payloads (JSON blobs in AzureDiagnostics, custom AppTraces), and time-series analytics (anomaly detection, forecasting) where the difference between bin and make-series is the difference between a real baseline and noise. The fix is never “learn more syntax” in the abstract — it is knowing, per operator, what it does, what the default is, when it’s wrong, and what it costs.

To frame the whole language before the deep dive, here is the KQL pipeline as a set of stages, the operators that live in each, and the one rule that governs the stage:

Pipeline stage Core operators What it does The governing rule
Source TableName, union, externaldata Names the tables to read Bound every source with a time filter; never union * unbounded
Filter where, search, take/limit Cuts rows before anything expensive Time filter first, then high-selectivity equality, then string matches
Shape project, project-away, project-rename, extend Narrows/renames/computes columns project early (fewer columns = less I/O); extend computes after shrinking
Aggregate summarize, count, dcount, arg_max, percentiles Collapses rows into groups Everything before by is an aggregate; keys go after by
Time bucket bin, make-series Turns rows into a time series bin for tables/charts; make-series … default=0 before any series_*
Correlate join, lookup, union Combines rows across tables Filter + project both sides; pick kind explicitly (default is innerunique)
Parse parse, parse_json, extract, mv-expand, mv-apply Extracts fields from strings/JSON/arrays mv-expand multiplies rows — apply it after filtering
Reuse let, materialize, stored functions Names sub-results and logic materialize() a sub-result used more than once to compute it once
Render/Act render, sort, scheduled query rule Visualises or alerts render is a portal hint (no data change); alerts run the same KQL on a schedule

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should already know the Azure Monitor data model at a high level: a Log Analytics workspace is a collection of tables, each with a fixed, typed schema, into which Azure resources send telemetry via diagnostic settings and the Azure Monitor Agent. You should be comfortable in the Log Analytics query editor (or az monitor log-analytics query), understand that every log table carries a TimeGenerated datetime column, and know the difference between metrics (pre-aggregated numeric time series, queried with the metrics API) and logs (raw typed rows, queried with KQL). Basic HTTP/status-code literacy and a working mental model of a relational join help.

This sits in the Observability track and is the query-language backbone under most of it. It assumes the platform context from The Azure Monitor Data Platform Explained: Metrics, Logs, Traces and Where Each Lives and pairs directly with Metrics vs Log Analytics: Choosing the Right Store for Speed, Cost and Retention, which decides what lands in KQL-queryable tables in the first place. The workspace it queries is designed in How to Create a Log Analytics Workspace: Region, Access Modes and Resource-Context RBAC, and the telemetry it reads arrives via Data Collection Rules and the Azure Monitor Agent and How to Wire Diagnostic Settings. Once your KQL is right, it powers alerts — How to Create Your First Metric Alert and Action Group and Killing Alert Noise: Tuning Thresholds, Suppression and Flapping — and, in security, KQL Threat Hunting Playbooks: MITRE ATT&CK Mapping, UEBA, and Hunting Notebooks. The same language, with a different resource surface, drives Azure Resource Graph Cookbook: KQL Queries for Inventory and Drift at Scale.

A quick map of where KQL is used across Azure, because the dialect and the constraints differ by surface:

Surface What you query KQL dialect notes Key constraint
Log Analytics / Azure Monitor Log tables (AppRequests, AzureDiagnostics, Perf, …) Full KQL Time filter on TimeGenerated; billing per GB ingested
Application Insights requests, exceptions, dependencies, traces, customEvents Full KQL; lowercase classic table names Same workspace-backed store (workspace-based AI)
Microsoft Sentinel Same workspace + SecurityEvent, SigninLogs, AuditLogs, watchlists Full KQL + _GetWatchlist(), ASIM functions Analytics rules run KQL on a schedule; watchlist joins
Azure Resource Graph ARM resource metadata (Resources, ResourceContainers) Subset of KQL (no join across some tables, no time series) No TimeGenerated; different operators available
Azure Data Explorer (ADX) Your own Kusto clusters Full KQL + control commands (.create, .ingest) Superset; where the language originates

Core concepts

Six mental models make every later operator obvious.

KQL is a read-only pipeline, and order is performance. A query is a source table followed by a chain of |-separated operators; each takes a tabular input and produces a tabular output, left to right. Nothing mutates data — you cannot UPDATE or DELETE. Because each stage feeds the next, where you put an operator changes how much data the next stage reads. Filtering rows early (where TimeGenerated > ago(1h)) means every downstream operator sees fewer rows; narrowing columns early (project) means less compressed data comes off disk. This is not stylistic — on a columnstore it is the single biggest lever on both latency and cost.

The store is a time-partitioned columnstore, and that dictates the two golden rules. Log Analytics stores each table partitioned by ingestion time and compressed per column. Two consequences drive almost every performance rule you will ever apply. First, filter on TimeGenerated first: the engine uses time to prune whole partitions (“data shards”) before reading anything, so a tight time window can skip 99% of the table. Second, select fewer columns: reading 5 columns instead of 40 reads a fraction of the compressed bytes off disk. Every other tip (has over contains, don’t wrap the filtered column in a function) is a corollary of “help the engine skip data.”

summarize is the workhorse, and the shape of its output is exact. summarize collapses many rows into one row per group. Everything before by is an aggregation function (count(), sum(x), dcount(id)); everything after by is a grouping key. The output has one column per aggregate plus one per key, and one row per distinct key combination. Get this shape wrong — put a key where an aggregate goes, or forget the by — and the result is not an error, it is a wrong-but-plausible table. Most “the numbers look off” bugs are a summarize whose grouping keys don’t match what you meant.

A time series is not just a summarize by bin(). Two ways to bucket by time look similar and behave differently. summarize count() by bin(TimeGenerated, 5m) produces a table with one row per non-empty bucket — empty buckets are simply absent. make-series count() default=0 on TimeGenerated step 5m produces vector-valued series — arrays of values and times, with gaps filled by the default. The series_* analytics functions (anomaly detection, forecasting) require the gap-filled, regular series from make-series; feed them a gappy bin result and the baseline is distorted because missing buckets read as “no data,” not “zero.”

A join is a cardinality bomb with a default you didn’t choose. join correlates rows from two tables on a key. Its behaviour is governed by kind=, and the default is innerunique, which first de-duplicates the left table’s key values, then joins — so a genuine one-to-many relationship can silently collapse to one-to-one and undercount. A relational inner join is kind=inner. Beyond the kind, a join reads the right table into memory to build a hash lookup, so a fat right side is the classic out-of-memory or slow join; you filter and project both sides and put the smaller table on the right. The join is where both correctness bugs (wrong kind) and cost/OOM bugs (fat right side, high-cardinality key) live.

dynamic and semi-structured data need explicit parsing and casting. Real logs carry strings and JSON, not clean columns. A dynamic-typed column (or a string parsed with parse_json) is a tree you index into (props.endpoint), and leaf values are untyped until you cast them (toint(props.statusCode), tostring(props.region)). Comparing or aggregating an uncast dynamic leaf silently does the wrong thing. When a JSON field is an array, mv-expand fans it to one row per element — which multiplies your row count, so it belongs after your filters, not before.

The vocabulary in one table

Before the deep sections, pin down every moving part. The glossary at the end repeats these for lookup; this table is the mental model side by side:

Concept One-line definition Why it matters
Workspace A Log Analytics container of tables The unit of RBAC, retention, billing, and join scope
Table A fixed, typed schema of columns What you query; getschema reveals it
TimeGenerated The datetime every log row carries The partition/prune key; filter it first, always
Tabular pipeline Source | operator | operator … The whole query model; order = performance
summarize Collapse rows into one per group Aggregates before by, keys after by
bin() Round a value down to a bucket Time-series tables; leaves empty buckets absent
make-series Gap-filled vector series Required input for series_* functions
join kind How two tables correlate Default innerunique ≠ relational inner
dynamic JSON/array/property-bag value Index into it; cast leaves before use
mv-expand Fan an array to one row per element Multiplies rows; place after filters
let Bind a name to a value/table/lambda Readability + reuse within one query
materialize() Cache a sub-result for reuse Compute an expensive sub-query once
Table plan Analytics / Basic / Auxiliary Query power vs ingestion price per table
Scheduled query rule KQL run on a schedule as an alert Turns a query into a pager
hint.strategy shuffle / broadcast join plan Override the engine when its plan is wrong

The tabular pipeline and how a query is read

A KQL query reads top to bottom, left to right: name a table, then pipe it through operators. The first non-trivial skill is reading a query the way the engine executes it — as a stream of tabular results, each narrower or smaller than the last.

AppRequests
| where TimeGenerated > ago(1h)     // 1. source, then prune by time
| where Success == false            // 2. high-selectivity equality
| where Url has "/api/checkout"     // 3. indexed term match on survivors
| project TimeGenerated, Name, DurationMs, ResultCode, OperationId  // 4. narrow columns
| extend DurationSec = DurationMs / 1000.0  // 5. compute on the small set
| sort by DurationMs desc           // 6. order the result
| take 20                           // 7. cap what returns

Each stage matters, and the order is deliberate. Before you can query a table you have not seen, discover its shape and where data lives:

AppRequests | getschema           // column names, types, ordinal

union withsource=SourceTable *    // which tables hold data, and how much
| where TimeGenerated > ago(1d)
| summarize Rows = count() by SourceTable
| sort by Rows desc

getschema returns the column list with types — indispensable before parsing or casting. The bounded union is the discovery pattern: withsource=SourceTable tags each row with its origin table so you can see which tables are populated. Run union * only while exploring and always bound it with a time filter — without one you ask the engine to touch every table for the full retention window, the single most common way people accidentally scan terabytes.

Here is the operator reference you will reach for constantly — what each does and the gotcha that separates a fast query from a slow one:

Operator What it does Gotcha / when to prefer it
where Filters rows by predicate Put time first; don’t wrap the filtered column in a function
project Keeps only listed columns (in order) Read fewer columns = less I/O; use early
project-away Drops listed columns, keeps the rest Cleaner than listing 30 columns to keep
project-rename Renames without computing Cheaper than extend NewName = OldName for a pure rename
project-keep Keeps columns matching names/wildcards project-keep *_d keeps all real-typed columns
extend Adds computed columns Computes, never filters; do it after shrinking rows
parse Extracts fields from a string by pattern Fails silently to empty on non-match; validate
mv-expand One row per array element Multiplies rows — apply after filtering
summarize Aggregates into groups Aggregates before by, keys after
make-series Gap-filled vector series Use before any series_* function
join Correlates two tables on a key Default innerunique; filter+project both sides
union Stacks tables/queries Bound with time; withsource= to tag origin
sort / order by Orders rows Expensive; do it last, on the small set
top N by Top-N without full sort Cheaper than sort | take for top-N
take / limit Returns N arbitrary rows Unordered sample; not “the first N”
distinct Unique combinations of columns Cheaper than summarize by when you want no aggregates
render Visualisation hint for the portal Changes no data; ignored outside the portal
evaluate Plugin invocation (bag_unpack, pivot, …) The extensibility hook for advanced shaping

Filtering and shaping fast: where, project, extend

The filter/shape stage is where you win or lose most of the query’s cost. Cut rows before anything expensive, narrow columns before you read them, and never help the engine less than you have to.

Filtering: put the cheap, selective predicates first

AppRequests
| where TimeGenerated between (ago(2h) .. ago(1h))   // bounded window, both ends
| where Success == false                             // equality, high selectivity
| where ResultCode in ("500", "502", "503")          // set membership, indexed
| where Url has "/api/checkout"                       // whole-term match on survivors

The ordering is not cosmetic. Time first prunes partitions. Then equality and in on high-selectivity columns cut the surviving rows sharply. String matching (has, contains) runs last, on the smallest set. The distinctions that matter most:

Predicate What it matches Indexed? Prefer when
== / != Exact value equality Yes You know the exact value
in (…) / !in (…) Membership in a set Yes A small fixed set of values
has / has_cs A whole indexed term (word) Yes (term index) Term-structured text (URLs, messages) — far faster than contains
contains / contains_cs A substring anywhere No (full scan) Genuinely need mid-term substrings; expect it to be slow
startswith / endswith Prefix / suffix Partial Anchored matches; startswith is cheaper than contains
matches regex A regular expression No Complex patterns; slowest — last resort
between (a .. b) Inclusive range Yes (on time) Bounded time or numeric windows
isnotempty() / isnotnull() Non-empty / non-null Yes Guarding parsed columns before aggregating

Two rules save more query time than any other. Never wrap the filtered column in a function: where toupper(Name) == "GET /" forces the engine to compute toupper on every row and defeats the index — filter the raw column, or use case-insensitive operators (=~, has). And has beats contains by orders of magnitude on a hot table, because has uses the term index while contains is an unindexed substring scan; on a busy AppTraces, that is the difference between two seconds and two minutes.

Shaping: project narrows, extend computes

project is the column-pruning lever — list exactly the columns you need, in the order you want them:

AppRequests
| where TimeGenerated > ago(1h)
| project TimeGenerated, Name, DurationMs, ResultCode, OperationId

Use the project family precisely: project-away to drop a few fat columns while keeping the rest; project-rename for a pure rename (cheaper than extend); project-keep with wildcards to keep a family of columns. extend adds computed columns and must come after you have shrunk the row set, because it runs its expression on every surviving row:

AppRequests
| where TimeGenerated > ago(1h)
| project TimeGenerated, Name, DurationMs, ResultCode
| extend
    DurationSec = DurationMs / 1000.0,
    Is5xx       = toint(ResultCode) >= 500,
    Endpoint    = tostring(split(Name, " ")[-1])

The shaping operators and when each is the right tool:

Task Operator Why it’s the right choice
Keep 5 of 40 columns project Reads far less compressed data off disk
Drop 2 fat columns, keep 38 project-away Avoids listing 38 columns
Rename DurationMsLatency project-rename No computation; cheaper than extend
Keep every *_d (real) column project-keep *_d Wildcard family selection
Add a computed DurationSec extend Only extend creates new columns
Reorder columns for readability project Output order follows the project list
Conditionally set a value extend x = iff(cond, a, b) iff/case inside extend

summarize, dcount, arg_max: aggregations that don’t lie

summarize is where rows become insight. The shape rule is absolute: aggregation functions before by, grouping keys after.

AppRequests
| where TimeGenerated > ago(24h)
| summarize
    Total    = count(),
    Failures = countif(Success == false),
    Users    = dcount(UserId),
    (P50, P95, P99) = percentiles(DurationMs, 50, 95, 99)
  by Name
| extend FailureRate = round(100.0 * Failures / Total, 2)
| sort by P95 desc

countif() counts a subset in the same pass without a separate branch. percentiles() returns several approximate-but-cheap percentiles at once — fine for SLOs, and far cheaper than exact percentiles. dcount() is an approximate distinct count using HyperLogLog — fast and memory-bounded on high-cardinality columns like OperationId or UserId, at the cost of a small error (a few percent); use count(distinct …)-style exactness only when you truly need it, because exact distinct on millions of values is expensive.

The aggregation functions you will actually use, and what each is for:

Function Returns Notes / gotcha
count() Row count The default; no argument
countif(pred) Count where predicate true Subset counting in one pass
sum(x) / sumif(x, pred) Sum / conditional sum Numeric columns only
avg(x) Mean Ignores nulls; watch for skew
min(x) / max(x) Extremes of a column Not the row — use arg_min/arg_max for the row
dcount(x) Approximate distinct count HyperLogLog; ~1–2% error, cheap on high cardinality
dcountif(x, pred) Conditional distinct count Approximate
percentiles(x, 50, 95, 99) Approximate percentiles Cheap; returns a tuple; great for SLOs
arg_max(x, *) The whole row at max x * returns all columns; the “latest per key” pattern
arg_min(x, *) The whole row at min x Earliest record per key
make_list(x) / make_set(x) Array of values / distinct values Bounded (default 1M); can blow memory
make_bag(dyn) Merge dynamics into one bag For property aggregation
stdev(x) / variance(x) Spread Anomaly-baseline building blocks
dcountif / sumif / avgif Conditional variants Prefer over a separate where + summarize

arg_max / arg_min deserve special mention because they solve a problem people otherwise butcher: “give me the whole latest record per resource.” summarize arg_max(TimeGenerated, *) by ResourceId returns, for each ResourceId, the entire row with the newest TimeGenerated — the canonical “latest state per entity” query, used everywhere from “current status per VM” to “most recent signin per user.”

bin: time-series buckets as a table

To turn rows into a time series table, group by a time bucket with bin():

AppRequests
| where TimeGenerated > ago(6h)
| summarize Requests = count(), AvgMs = avg(DurationMs)
    by bin(TimeGenerated, 5m), Name
| render timechart

bin(TimeGenerated, 5m) rounds each timestamp down to a 5-minute boundary, so count() becomes requests-per-5-minutes. render timechart is a portal hint that visualises the result — it changes no data and is ignored by the API — but it sanity-checks the shape instantly. The critical limitation: bin produces no row for an empty bucket. If no requests landed between 03:15 and 03:20, that bucket is simply absent from the result. For a chart the portal interpolates and it looks fine; for downstream math or anomaly detection, the missing bucket is a hole, not a zero — which is exactly why you reach for make-series when a series_* function is next.

The time-bucketing choices and when each fits:

Approach Output shape Empty buckets Use for
summarize by bin(TimeGenerated, 5m) Table (row per non-empty bucket) Absent Charts, dashboards, ad-hoc trends
make-series … default=0 on TimeGenerated step 5m Vector series (value + time arrays) Filled with default Any series_* function; math over buckets
summarize by bin_at(TimeGenerated, 1d, startofday(now())) Table, aligned to a fixed anchor Absent Day/week buckets aligned to a boundary

Joins, unions, and lookups across tables

Correlation across tables is where KQL gets both powerful and dangerous. Three operators do it: union stacks tables with compatible schemas; join correlates rows on a key; lookup enriches from a small dimension table.

The join kinds — pick deliberately, because the default surprises you

A typical join ties a failed request to the exception it threw, by operation:

AppExceptions
| where TimeGenerated > ago(1h)
| project OperationId, ProblemId, ExceptionType = Type, ExceptionMessage = OuterMessage
| join kind=inner (
    AppRequests
    | where TimeGenerated > ago(1h)
    | where Success == false
    | project OperationId, Name, Url, DurationMs
  ) on OperationId

Two mechanics decide whether a join is correct and survivable. First, filter and project both sides first: the engine reads the right table into memory to build the hash table, so a fat right side is the classic slow/OOM join — put the smaller table on the right. Second, pick the kind deliberately, because the default innerunique de-duplicates the left key before joining and surprises anyone who expected relational inner.

The complete join-kind matrix — memorise the top four, know the rest exist:

kind Keeps Columns returned Use when
innerunique (default) Deduped left keys × matches Both sides You didn’t specify — beware silent de-dup of the left
inner Every matching pair (all dups) Both sides A true relational inner join
leftouter All left rows; nulls where no match Both sides Enrich left without dropping unmatched rows
rightouter All right rows; nulls where no match Both sides Same, keeping the right side whole
fullouter All rows from both; nulls to fill Both sides Reconcile two sets, keep everything
leftsemi Left rows that have a match Left only “Which requests had an exception?”
rightsemi Right rows that have a match Right only Mirror of leftsemi
leftanti Left rows with no match Left only “Which hosts sent no heartbeat?”
rightanti Right rows with no match Right only Mirror of leftanti

leftanti earns a callout: it cleanly replaces awkward NOT-IN logic. The canonical use is silent-host detection — take distinct Computer from Heartbeat over the last hour, join kind=leftanti against distinct Computer from the last 15 minutes, and what survives is the machines that went quiet:

Heartbeat
| where TimeGenerated > ago(1h)
| distinct Computer
| join kind=leftanti (
    Heartbeat
    | where TimeGenerated > ago(15m)
    | distinct Computer
  ) on Computer
// result: computers seen in the last hour but NOT the last 15 min → went silent

The failure modes that make joins wrong or slow, and how to avoid each:

Symptom Cause Fix
Counts lower than expected; 1:many became 1:1 Default innerunique de-duped the left key Specify kind=inner
Join is slow / hits memory limits Fat right side read into memory Filter + project the right side; put the smaller table right
Unmatched rows silently dropped Used inner when you needed to keep all left Use kind=leftouter
Query times out on a huge key space Single-node join on a high-cardinality key Add hint.strategy=shuffle
Enrichment shuffles a tiny table join used for a small dimension lookup Use lookup (broadcasts the dimension)
“Ambiguous column” errors Both sides carry the same column name project-rename one side, or use $left/$right

union — stack compatible tables

union concatenates tables. It is the tool for “search across all my tables” and for combining shards of the same logical data (e.g., an old and a new table version):

union withsource=SourceTable (AppRequests), (AppDependencies)
| where TimeGenerated > ago(1h)
| where Success == false
| summarize Failures = count() by SourceTable, bin(TimeGenerated, 5m)

withsource=SourceTable tags each row with its origin — essential when the union spans tables with overlapping schemas. Prefer an explicit, parenthesised list of tables over union *; if you must wildcard (e.g. union App*), still bound it with a time filter. Column resolution follows a union mode: by default columns present in some but not all tables become nullable in the output.

lookup — the right way to enrich from a small dimension table

For small, static lookups (region maps, owner tables, watchlists), use lookup with an inline datatable. The engine broadcasts the dimension table to every node rather than shuffling the big table — far cheaper than a join for this pattern:

let owners = datatable(Service:string, Team:string) [
    "checkout", "payments",
    "search",   "discovery",
    "profile",  "identity"
];
AppRequests
| where TimeGenerated > ago(1h)
| extend Service = tostring(split(Url, "/")[1])
| lookup kind=leftouter owners on Service
| summarize Requests = count() by Team

lookup supports kind=leftouter (default — keep all left rows) and kind=inner. Reach for it whenever the right side is a small, static, or slowly-changing dimension; reach for join when both sides are large fact tables.

When to use which correlation operator:

Operator Best for Cost model Key behaviour
join Two large fact tables on a key Right side into memory (hash); can shuffle Six kinds; default innerunique
lookup Enriching from a small dimension table Broadcasts the dimension leftouter/inner; no shuffle
union Stacking compatible tables Reads each table once withsource=; nullable columns across schemas
mv-expand + mv-apply Correlating within one row’s array In-row expansion Not a cross-table join

Steering the engine: shuffle and broadcast hints

Most of the time the engine picks a good plan. When it doesn’t, two hint.strategy values change how a join (or summarize) is distributed across the cluster’s nodes.

hint.strategy=shuffle repartitions both sides by the join key so each node handles a slice of the key space. It is the fix when the join key has very high cardinality (millions of distinct values) and a single-node join runs out of memory or time. You can also hint the number of partitions with hint.shufflekey=<column>:

AppRequests
| where TimeGenerated > ago(1d)
| join hint.strategy=shuffle (
    AppDependencies
    | where TimeGenerated > ago(1d)
  ) on OperationId

hint.strategy=broadcast sends the (small) left side to every node holding the right side — the same idea lookup uses automatically, but expressed on a join when the left side is genuinely small (roughly under ~100k rows):

SmallLeftTable            // a small, filtered result
| join hint.strategy=broadcast (
    HugeRightTable
    | where TimeGenerated > ago(1h)
  ) on Key

The rule of thumb: broadcast when one side is small; shuffle when both sides are large and the key is high-cardinality. The strategies at a glance:

Strategy What it does Use when Don’t use when
(default) Engine picks; often single-node join Small/medium data; you trust the plan The plan is provably wrong (OOM/timeout)
hint.strategy=broadcast Ships the small side to every node One side is small (< ~100k rows) Both sides are large (memory blow-up)
hint.strategy=shuffle Repartitions both sides by the key Both sides large; high-cardinality key Low-cardinality key (over-shuffles)
hint.shufflekey=<col> Sets the shuffle partition column Shuffle chose a skewed key You don’t understand the key distribution
hint.num_partitions=<n> Tunes shuffle parallelism Default partition count underperforms You haven’t measured

Parsing semi-structured logs

Real logs are rarely fully columnar. KQL gives you a ladder of parsing tools, from the cheapest and most structured to the most flexible and slowest.

parse — extract named fields by example pattern

parse reads a string and pulls named, typed fields out of it by an example pattern:

AppTraces
| where TimeGenerated > ago(1h)
| parse Message with "user=" UserId " action=" Action " ms=" Latency:long
| where isnotempty(UserId)
| project TimeGenerated, UserId, Action, Latency

parse is fast and readable when the format is stable. Its trap: on a non-matching line it silently sets the fields to empty, not an error — so guard with isnotempty() before aggregating, or you count blanks. Use parse-where (which drops non-matching rows) when you only want lines that fit the pattern.

extract and extract_all — regex capture

When the format is irregular, fall back to extract (one capture group) or extract_all (all matches):

AppTraces
| where TimeGenerated > ago(1h)
| extend StatusCode = extract(@"status=(\d{3})", 1, Message)
| where isnotempty(StatusCode)
| summarize count() by StatusCode

Regex is the flexible-but-slow rung; prefer parse when the format allows it.

parse_json — turn a string into a navigable dynamic

For JSON, parse the string into a dynamic object with parse_json (alias todynamic), then index into it, casting leaf values to a concrete type before comparing or aggregating:

AzureDiagnostics
| where TimeGenerated > ago(1h)
| extend props = parse_json(properties_s)
| extend
    Endpoint = tostring(props.endpoint),
    Code     = toint(props.statusCode),
    Region   = tostring(props.region)
| summarize count() by Endpoint, Code

The most common waste here: if a column is already dynamic (many Azure tables store JSON natively — check with getschema), skip parse_json and index it directly; re-parsing an already-parsed object is pure overhead. And always cast the leafprops.statusCode is untyped dynamic; comparing it to an integer without toint() does something you didn’t intend.

The type-cast functions you’ll pair with parsing:

Cast Turns dynamic/string into Note
tostring(x) string Safe default for text leaves
toint(x) / tolong(x) 32-/64-bit integer Returns null on non-numeric; guard it
todouble(x) / toreal(x) Floating point For latencies, ratios
todatetime(x) datetime ISO-8601 strings parse cleanly
tobool(x) boolean “true”/“false”/1/0
toguid(x) guid Correlation IDs
todynamic(x) / parse_json(x) dynamic String → navigable tree
tostring(dyn.field) Leaf → text The idiomatic “pull one field”

mv-expand and mv-apply — arrays into rows

When a JSON field is an array, mv-expand fans it to one row per element so you can aggregate nested data:

AppTraces
| where TimeGenerated > ago(1h)
| extend payload = parse_json(Message)
| mv-expand item = payload.items
| extend Sku = tostring(item.sku), Qty = toint(item.qty)
| summarize Units = sum(Qty) by Sku

mv-expand multiplies rows — a 10-element array becomes 10 rows — so apply it after filtering, never before, or you explode the working set. mv-apply is the variant that runs a sub-query per array: use it when you need to filter, sort, or aggregate within each row’s array before flattening. For example, “the single highest-quantity item per trace”:

AppTraces
| where TimeGenerated > ago(1h)
| extend payload = parse_json(Message)
| mv-apply item = payload.items on (
    top 1 by toint(item.qty) desc
  )
| extend TopSku = tostring(item.sku), TopQty = toint(item.qty)

The parsing ladder, cheapest and most structured to most flexible and slowest:

Tool Best for Cost Silent-failure risk
split() Splitting on a single delimiter Cheap Wrong index → empty
parse … with Fixed, example-shaped strings Cheap Non-match → empty fields (guard!)
parse-where … with Same, but drop non-matches Cheap Drops rows silently
extract(regex, n, src) One capture group Medium Non-match → empty
extract_all(regex, src) Many captures Medium Returns empty array on no match
parse_json / todynamic JSON strings → navigable tree Medium Uncast leaf compares wrongly
mv-expand Array → one row per element Multiplies rows Explodes cardinality if before filters
mv-apply Sub-query per array Higher Wrong sub-query semantics
bag_unpack (via evaluate) Dynamic bag → columns Higher Column explosion on wide bags

Time-series analytics: make-series and the series functions

summarize … by bin() gives a table; make-series gives vector-valued series with gap-filling, which the analytics functions require.

let step = 10m;
AppRequests
| where TimeGenerated > ago(2d)
| make-series Reqs = count() default = 0
    on TimeGenerated step step by Name

default = 0 is the important part: missing buckets become 0 instead of holes, so downstream math does not silently skip them. The result is one row per Name, each carrying a value array (Reqs) and a matching time array (TimeGenerated). On top of that structure, the series_* functions operate.

Anomaly detectionseries_decompose_anomalies splits the series into seasonal + trend + residual and flags points the residual cannot explain:

let step = 10m;
AppRequests
| where TimeGenerated > ago(7d)
| make-series Reqs = count() default = 0
    on TimeGenerated step step
| extend (anomalies, score, baseline) =
    series_decompose_anomalies(Reqs, 1.5)
| render anomalychart

The second argument is the sensitivity threshold in residual standard deviations; lower is more sensitive. It returns three series — a flag (-1/0/1 for negative/none/positive anomaly), a score, and the computed baseline.

Forecasting — swap in series_decompose_forecast on the same make-series output to project points ahead (here, 24 steps into the future):

let step = 1h;
AppRequests
| where TimeGenerated > ago(14d)
| make-series Reqs = count() default = 0
    on TimeGenerated from ago(14d) to now() + 1d step step
| extend Forecast = series_decompose_forecast(Reqs, toint(1d / step))
| render timechart

Both assume a roughly regular, seasonal signal and overfit on sparse or bursty data — always eyeball the baseline before trusting the flags, and prefer at least one to two full seasonal periods of history. The series toolbox:

Function What it computes Typical use
series_decompose_anomalies(s, t) Anomaly flags + score + baseline Detecting spikes/dips a static threshold would miss
series_decompose_forecast(s, n) n future points Capacity projection, “will we breach by Friday?”
series_decompose(s) Seasonal + trend + residual + baseline Understanding why a series moves
series_fir(s, filter) Finite impulse response (moving avg) Smoothing before eyeballing
series_iir(s, num, den) Infinite impulse response filter Exponential smoothing
series_stats(s) min/max/avg/stdev of a series Series-level summary stats
series_fit_line(s) Linear trend line + slope/R² “Is this trending up?”
series_outliers(s) Per-point outlier scores Lightweight anomaly scoring
series_pearson_correlation(a, b) Correlation of two series “Do these two metrics move together?”

The bin-versus-make-series decision, stated once more as a rule because it is the single most common time-series mistake:

You want… Use Because
A chart or table of counts over time summarize by bin() Simpler; empty buckets don’t matter for a chart
Input to any series_* function make-series … default=0 Functions need regular, gap-filled series
Math across buckets (ratios, deltas) make-series … default=0 Missing buckets must be 0, not absent
A fixed number of future points make-series + series_decompose_forecast Forecast needs the vector form

Reusing logic: let, functions, and materialize

As queries grow, let, functions, and materialize() keep them readable and fast.

let — name values, tables, and lambdas

let binds a name to a scalar, a tabular expression, or a lambda, scoped to the query:

let lookback = 24h;
let slowThresholdMs = 1000;
let SlowRequests =
    AppRequests
    | where TimeGenerated > ago(lookback)
    | where DurationMs > slowThresholdMs;
SlowRequests
| summarize Slow = count() by Name
| join kind=inner (
    SlowRequests | summarize Total = count() by Name
  ) on Name

let improves readability and lets you reference the same sub-result by name. But a plain let-bound tabular expression is re-evaluated on each referenceSlowRequests above is computed twice. That is where materialize() comes in.

materialize — compute an expensive sub-result once

Wrap a tabular let in materialize() and the engine computes it once, caches the result for the query’s lifetime, and reuses it on every reference:

let SlowRequests = materialize(
    AppRequests
    | where TimeGenerated > ago(24h)
    | where DurationMs > 1000
);
SlowRequests | summarize Slow = count() by Name
| join kind=inner (
    SlowRequests | summarize Total = count() by Name
  ) on Name
| extend Pct = round(100.0 * Slow / Total, 1)

materialize() is the highest-leverage performance tool for any query that references the same sub-result more than once (both sides of a join, a union of aggregates, a fan-out) — it turns N scans into one. The trade-off is memory: the materialized result is held for the query, so materialize a filtered, narrowed sub-result, not a raw table.

let binding types and when materialization helps:

let binds… Example Re-evaluated per use? Wrap in materialize()?
A scalar let t = 24h; No (constant) No
A tabular expression used once let X = T | where …; Once No benefit
A tabular expression used many times join both sides, fan-out Yes, each time Yes — compute once
A lambda / function let f = (x:long){ x*2 }; Per call N/A

Functions — package logic for reuse across queries

A query-scoped function is a let-bound lambda; a stored function (saved in the workspace) is reusable across all queries and users — the way teams share detection logic, ASIM parsers, and common transformations. Save one via the portal (“Save as function”) or with az:

// Query-scoped function
let failureRate = (T:(Success:bool)) {
    T | summarize Total = count(), Fails = countif(Success == false)
      | extend Rate = round(100.0 * Fails / Total, 2)
};
AppRequests | where TimeGenerated > ago(1h) | invoke failureRate()
# Save a reusable stored function (a "saved search" of category Function) in the workspace
az monitor log-analytics workspace saved-search create \
  --resource-group rg-observability --workspace-name law-prod \
  --saved-search-id "FailedCheckouts" \
  --category "Function" --display-name "FailedCheckouts" \
  --function-alias "FailedCheckouts" \
  --query "AppRequests | where Name has '/checkout' and Success == false"

Once saved with a --function-alias, FailedCheckouts becomes a callable table-valued function in any query in that workspace — the mechanism behind Sentinel’s ASIM normalization and every team’s shared query library.

Common query patterns you’ll reach for constantly

A handful of patterns recur across observability and security. Learn them once; reuse them everywhere.

Top-N per group

“The three slowest endpoints per cloud role” needs top-nested, not a global top:

AppRequests
| where TimeGenerated > ago(1h)
| top-nested 5 of cloud_RoleName by count(),
  top-nested 3 of Name by avg(DurationMs)

top-nested produces the top-K within each outer group — the clean answer to “top N per category,” which people otherwise fake with row-number tricks.

Latest state per entity

Covered above but worth repeating as a pattern: arg_max(TimeGenerated, *) per key returns the whole newest row:

Heartbeat
| where TimeGenerated > ago(1h)
| summarize arg_max(TimeGenerated, *) by Computer   // current state of each host

Sessionization — group events into sessions by an idle gap

Turn a stream of per-user events into sessions, where a gap longer than N minutes starts a new session. Use prev() over an ordered partition:

let idleGap = 30m;
AppPageViews
| where TimeGenerated > ago(1d)
| order by UserId asc, TimeGenerated asc
| extend Gap = TimeGenerated - prev(TimeGenerated)
| extend NewSession = iff(UserId != prev(UserId) or Gap > idleGap, 1, 0)
| serialize SessionId = row_cumsum(NewSession)
| summarize Start = min(TimeGenerated), End = max(TimeGenerated), Events = count()
    by UserId, SessionId

serialize freezes row order so the windowed functions (prev, row_cumsum) are deterministic; row_cumsum running-sums the session-boundary flags into a stable session id.

Funnel — conversion through ordered steps

Count how many users progress through an ordered set of steps:

let window = 1d;
AppEvents
| where TimeGenerated > ago(window)
| where EventName in ("view", "add_to_cart", "checkout", "purchase")
| summarize Steps = make_set(EventName) by UserId
| extend
    Viewed    = set_has_element(Steps, "view"),
    Carted    = set_has_element(Steps, "add_to_cart"),
    CheckedOut= set_has_element(Steps, "checkout"),
    Purchased = set_has_element(Steps, "purchase")
| summarize
    Viewed = countif(Viewed), Carted = countif(Carted),
    CheckedOut = countif(CheckedOut), Purchased = countif(Purchased)

The pattern catalogue, with the operator that powers each:

Pattern Key operator(s) One-line purpose
Top-N per group top-nested Top K within each category
Latest state per entity summarize arg_max(TimeGenerated, *) by Key Current row per entity
Silent-host detection join kind=leftanti Present before, absent now
Sessionization prev(), serialize, row_cumsum Group events by idle gap
Funnel / conversion make_set + set_has_element Progress through ordered steps
Time-series anomaly make-series + series_decompose_anomalies Spikes a static threshold misses
Pivot (rows → columns) evaluate pivot() Cross-tab a category
Running total row_cumsum Cumulative sum over ordered rows
Percentiles per group summarize percentiles(x, …) by Key Latency SLOs per endpoint
Rate over time summarize count() by bin(t, 1m) Requests/errors per minute

Query performance and the columnstore

Everything above has a cost dimension. On a columnstore, a query’s price is roughly how much compressed data it must read, and you control that with a small set of rules. First, the rules ranked by impact:

Rule Why it works Impact
Time filter first Prunes whole time partitions before any read Highest — can skip 99% of a table
project early / narrow Reads fewer columns off the columnstore High — proportional to columns dropped
has/==, not contains Uses the term index instead of a full scan High on hot tables
Don’t wrap filtered columns in functions Keeps the predicate index-eligible High — where toupper(x)==… scans all
Filter + project both join sides Shrinks the in-memory right side High for joins
materialize() reused sub-results Computes an expensive sub-query once High for multi-reference queries
summarize before join where possible Joins fewer, pre-aggregated rows Medium–high
take/top to cap output Less data serialized back Medium
dcount over exact distinct HyperLogLog is memory-bounded Medium on high cardinality
hint.strategy when the plan is wrong Distributes a skewed/huge join Situational but decisive

The single most useful skill is reading what a query cost. In the Log Analytics editor, the Stats / query-details pane after a run shows CPU, memory, and — crucially — data scanned. Run a query with and without its time filter and compare the scanned bytes; the difference is the lesson. Programmatically, az monitor log-analytics query returns timing, and the workspace’s own Usage/query-audit tables record heavy queries.

The anti-patterns that quietly cost the most, and the fix for each:

Anti-pattern What it does Fix
union * with no time filter Scans every table for full retention Name tables explicitly; add a time filter
search "text" across the workspace Full-text scan of all tables Target a table and column with has
contains on a hot table Unindexed substring scan has / startswith / ==
where toupper(Col) == "X" Computes on every row; no index Filter the raw column; use =~/has
join with an unfiltered right side Reads a huge table into memory Filter + project the right side first
sort before filtering/aggregating Orders more rows than needed sort/top last, on the small set
mv-expand before filters Multiplies the whole working set Filter first, expand last
Exact distinct on millions of IDs Memory-heavy exact count dcount() (approximate)
Re-parse_json an already-dynamic column Redundant parse per row Index the dynamic column directly
let sub-result used many times Re-evaluated each reference Wrap in materialize()

Workspace design and the table plans that shape KQL

KQL performance and cost are inseparable from how the workspace is designed, and the biggest lever is the table plan. Every table in a Log Analytics workspace has a plan that trades query capability for ingestion price:

Plan KQL capability Retention model Billing Use for
Analytics Full KQL, all operators, alerts, fast interactive Interactive (up to 2 yrs) + long-term archive Per-GB ingested Signals you query interactively and alert on
Basic Logs Reduced KQL (limited operators, no join on some paths, no alerts on the table) Short interactive (8 days), then long-term Per-GB ingested (cheaper) + per-GB scanned at query High-volume, rarely-queried logs (verbose app/firewall traces)
Auxiliary Lowest capability, batch-oriented query Long-term archive Lowest ingestion; per-query scan Cheap, occasionally-investigated data

Setting a verbose, low-value table to Basic sharply cuts its ingestion price, at the cost of interactive querying and alerting — and it changes what KQL you can run against it. Choose per table by how often you query it:

# Move a chatty, rarely-queried table to Basic Logs
az monitor log-analytics workspace table update \
  --resource-group rg-observability --workspace-name law-prod \
  --name ContainerLogV2 --plan Basic

The table-plan implications for how you write KQL:

If the table is on… You can… You cannot… Query cost model
Analytics Full KQL, join, alerts, dashboards Included in ingestion; scans are free
Basic Logs Simple filters/project/parse, search Alert on it; some join paths; long interactive Pay per GB scanned — narrow the window hard
Auxiliary Batch/async queries Interactive dashboards Per-query scan; slower

Two workspace-design facts shape every cross-table query you write. join scope is the workspace — you can join across tables in one workspace freely, but joining across workspaces needs the workspace("name").Table reference. And RBAC can be resource-context or workspace-context, which changes what rows a query returns for a given user — a resource-context reader sees only rows for resources they can access, so the same KQL can return different results per identity. The workspace-referencing functions:

Reference Syntax Use for
Same-workspace table AppRequests The default; fastest
Cross-workspace workspace("law-prod").AppRequests Federated queries across workspaces
Cross-resource (App Insights) app("my-ai").requests Query a classic App Insights resource
Cross-resource (specific resource) resource("…/microsoft.insights/…") Scope to one resource’s logs
Sentinel watchlist _GetWatchlist("HighValueAssets") Join telemetry against a curated list

Architecture at a glance

Picture the path a single question takes, from a table on disk to a chart or a pager, because the shape of that path is why the performance and correctness rules exist. At the bottom sits the Log Analytics workspace: a set of tables, each a time-partitioned, per-column-compressed columnstore. A query enters as text and the engine plans it: the time filter on TimeGenerated is read first and prunes whole partitions, so the very next stage — reading columns — touches only the shards that survive. The narrower your project, the fewer compressed column-blocks come off disk. This is the front third of the pipeline, and it is where 90% of the cost is decided.

From there the surviving rows flow through the middle of the pipeline — where predicates using the term index (has) or a full scan (contains), summarize/bin/make-series collapsing rows into groups or gap-filled series, join/lookup/union pulling in a second table (the right side loaded into memory as a hash, or a small dimension broadcast to every node), and parse/parse_json/mv-expand tearing strings and arrays apart (the last of which multiplies rows, which is why it sits after the filters). let and materialize() name and cache sub-results so a fan-out computes once instead of N times.

The tail of the pipeline is where the same query splits into three destinations. Interactively, render hints the portal to draw a chart and the Stats pane reports what you scanned — the feedback loop that teaches you cost. As a scheduled query rule, the identical KQL runs on a timer (evaluationFrequency) over a lookback window (windowSize), and when the aggregated result crosses a threshold it fires an action group — a query becomes a pager. And in Sentinel, the same language runs as an analytics rule against security tables and watchlists, turning a hunt into a detection. One language, one columnstore, three exits: chart, alert, detection. The whole method is: prune by time, read narrow, correlate deliberately, cast what you parse, and know which exit you are writing for.

Real-world scenario

Meridian Freight, a logistics SaaS, runs an event-driven platform on Azure: API Management in front, ~40 App Services and 12 AKS workloads behind, all wired to one Log Analytics workspace (law-meridian-prod) in Central India. The observability team is three engineers; the workspace ingests about 900 GB/day and the monthly Azure Monitor bill sits near ₹9,00,000. Their dashboards are widely used and widely distrusted — the “failure rate by endpoint” tile never matches what customers report, and the on-call SLO burn alert has a reputation for both missing real incidents and paging on nothing.

The incident that forced a reckoning was a Tuesday-morning latency spike. The p95-latency dashboard showed a clean 40 ms baseline with a sudden 900 ms tower at 09:12 — an obvious outage. On-call escalated, three engineers joined a bridge, and twenty-five minutes in nobody could find a matching error spike or a deploy. The “outage” was a query bug: the latency tile used summarize percentile(DurationMs, 95) by bin(TimeGenerated, 1m) over a low-traffic internal admin endpoint mixed into the same tile as the customer API. During a quiet minute the admin endpoint had exactly one slow request, and with bin leaving empty minutes absent, the chart’s autoscaling drew that single 900 ms point as a tower. There was no incident. Twenty-five minutes and three engineers, spent on a bin-versus-make-series artefact and an un-split dimension.

The audit that followed found three systemic KQL problems. First, the failure-rate mismatch: the tile joined AppRequests to AppExceptions with a bare join — the default innerunique — which de-duplicated the request side and undercounted every endpoint with more than one failure per operation, so the dashboard structurally reported fewer failures than reality. Switching to kind=inner and pre-aggregating with summarize before the join reconciled the tile with customer reports on the first try. Second, the SLO burn alert ran AppRequests | summarize … | where BurnRate > 2 on a query that used contains "/api/" on the URL — an unindexed scan of a 200 GB/day table every five minutes — and its own slowness meant it sometimes evaluated stale windows and missed fast burns. Rewriting the filter to Url has "/api/" and bounding the window tightly cut the rule’s runtime from ~40 s to under 3 s. Third, the bill: the top DataType in Usage was a single ContainerLogV2 stream of INFO-level request/response bodies nobody queried interactively — 300 GB/day of the 900.

The fixes landed in two waves. Immediately, the team rewrote the four worst dashboard tiles: make-series … default=0 for every latency and rate chart (no more phantom towers), kind=inner with pre-aggregation for every correlation tile, and has/== filters throughout. Over the next two weeks they moved the verbose ContainerLogV2 body stream to Basic Logs (they still needed it for rare deep-dives, just not interactively) and added a DCR transformation to drop DEBUG at ingestion. Net effect: the latency dashboard stopped crying wolf, the failure-rate tile finally matched reality, the SLO alert caught the next real burn in under a minute, and ingestion fell from 900 to ~560 GB/day — the monthly bill dropped by roughly ₹2,50,000 with no loss of signal. The line the lead wrote on the wall: “A KQL dashboard is only as trustworthy as its worst join kind. Read the query before you trust the chart.”

The incident and audit as a timeline, because the order of realizations is the lesson:

Time / phase Symptom What they thought What it actually was
09:12 900 ms tower on p95 tile Latency outage bin phantom + un-split admin endpoint
09:37 No matching errors/deploy “Silent” outage No incident — a chart artefact
Audit day 1 Failure rate never matches reality Instrumentation gap Default innerunique undercounting
Audit day 1 SLO alert misses fast burns Threshold too high contains scan too slow to evaluate in time
Audit day 2 Bill doubled after a rollout More traffic 300 GB/day of unqueried INFO bodies
Week 1 Tiles rewritten: make-series, kind=inner, has
Week 2 Verbose table → Basic Logs; DEBUG dropped in DCR

Advantages and disadvantages

KQL’s design — a read-only pipeline over a columnstore, with powerful defaults chosen for the common case — is what makes it fast to write and easy to get subtly wrong. Weigh it honestly:

Advantages (why the language helps you) Disadvantages (why it bites)
Pipeline reads top-to-bottom; easy to compose and reason about incrementally Order is performance — a correct-looking query can be needlessly expensive
Columnstore + time partitioning make time-scoped queries extremely fast Forget the time filter and you scan full retention; the cost is invisible until the bill
Rich time-series and ML functions (make-series, series_decompose_*) built in They assume regular, seasonal data and overfit on sparse/bursty series
Sensible defaults (innerunique, approximate dcount) keep common queries cheap The default is often not what you meant — innerunique silently changes counts
One language across Monitor, App Insights, Sentinel, Resource Graph, ADX Dialects differ (Resource Graph is a subset; no time series, limited join)
First-class parsing (parse, parse_json, mv-expand) for messy real logs Uncast dynamic leaves and non-matching parse fail silently to empty
Same query drops straight into alerts and Sentinel detections An alert query that returns zero for the wrong reason simply never fires
materialize() + let + functions make big queries fast and reusable Misused materialize holds memory; a plain let re-evaluates each reference

The language is right for anyone who lives in Azure telemetry — the moment you need cross-table correlation, time-series analytics, or semi-structured parsing, KQL beats exporting to a spreadsheet or a general-purpose tool. It bites hardest on people who treat it like SQL (the join default is different), who build dashboards without reading the underlying queries (phantom towers, undercounts), and who ignore the cost dimension until a doubled bill forces it. Every disadvantage is manageable — but only if you know the default, the silent failure, and the cost of each operator, which is the entire point of this article.

Hands-on lab

Build a small Log Analytics workspace, ingest a bit of custom data, and run the exact patterns above against it — correctness and cost, end to end. Free-tier-friendly (a tiny workspace ingesting a few MB costs pennies; delete at the end). Run in Cloud Shell (Bash).

Step 1 — Variables and resource group.

RG=rg-kql-lab
LOC=centralindia
WS=law-kql-lab-$RANDOM
az group create -n $RG -l $LOC -o table

Step 2 — Create the Log Analytics workspace.

az monitor log-analytics workspace create \
  --resource-group $RG --workspace-name $WS --location $LOC -o table
WSID=$(az monitor log-analytics workspace show \
  --resource-group $RG --workspace-name $WS --query customerId -o tsv)
echo "Workspace GUID: $WSID"

Expected: a workspace row; WSID is the GUID you query against.

Step 3 — Run a schema/discovery query. Use the always-present Heartbeat-style Operation/Usage tables, or query Usage (populated within minutes) to prove the query path works:

az monitor log-analytics query \
  --workspace "$WSID" \
  --analytics-query "Usage | where TimeGenerated > ago(1d) | summarize GB = sum(Quantity)/1024 by DataType | sort by GB desc | take 5" \
  -o table

Expected: a small table of DataType and GB (may be near-zero on a fresh workspace — that is fine; the point is the query executes).

Step 4 — Prove the time-filter cost rule. Run the same aggregate with a tight and a wide window and note the difference in the returned Stats (via --query on the response, or the portal Stats pane):

# Tight window
az monitor log-analytics query --workspace "$WSID" \
  --analytics-query "AzureActivity | where TimeGenerated > ago(1h) | count" -o table
# Wide window (scans far more)
az monitor log-analytics query --workspace "$WSID" \
  --analytics-query "AzureActivity | where TimeGenerated > ago(30d) | count" -o table

The lesson is the mechanism, not the number: the wide query reads far more partitions to answer the same shape of question.

Step 5 — Run each core pattern in the portal editor. Open the workspace → Logs, and run these against AzureActivity (present in every subscription) to exercise the operators:

// summarize + bin (time-series table)
AzureActivity
| where TimeGenerated > ago(7d)
| summarize Ops = count() by bin(TimeGenerated, 1h), OperationNameValue
| render timechart
// make-series + anomaly (the correct time-series form)
AzureActivity
| where TimeGenerated > ago(7d)
| make-series Ops = count() default = 0 on TimeGenerated step 1h
| extend (anomalies, score, baseline) = series_decompose_anomalies(Ops, 1.5)
| render anomalychart
// arg_max: latest activity per caller
AzureActivity
| where TimeGenerated > ago(1d)
| summarize arg_max(TimeGenerated, OperationNameValue, ActivityStatusValue) by Caller

Expected: the first renders a time chart; the second an anomaly chart with a baseline; the third one row per Caller with their newest operation.

Step 6 — Save a reusable function.

az monitor log-analytics workspace saved-search create \
  --resource-group $RG --workspace-name $WS \
  --saved-search-id "RecentFailures" --category "Function" \
  --display-name "RecentFailures" --function-alias "RecentFailures" \
  --query "AzureActivity | where TimeGenerated > ago(1h) and ActivityStatusValue == 'Failure'"

In the editor, run RecentFailures | count — the saved function is now callable like a table.

Validation checklist. You created a workspace, proved the query path, demonstrated the time-filter cost rule, ran summarize/bin, the correct make-series + anomaly form, arg_max for latest-state, and saved a reusable function. The lab steps mapped to what each proves:

Step What you did What it proves
3 Usage discovery query The query path and the “what does this cost” table
4 Tight vs wide window count Time filter prunes partitions (the #1 cost rule)
5a summarize by bin() Time-series table; empty buckets absent
5b make-series … default=0 + anomaly The correct series form for series_* functions
5c arg_max(TimeGenerated, *) Latest-state-per-entity pattern
6 Saved function Reusable logic across queries

Cleanup (avoid lingering charges).

az group delete -n $RG --yes --no-wait

Cost note. A tiny workspace ingesting a few MB during this lab costs well under ₹20; deleting the resource group stops all charges. (Note the 31-day soft-delete on workspaces — the delete removes it from billing immediately.)

Common mistakes & troubleshooting

The KQL failures that cost the most time — as a scannable table you can keep open while you debug, then the reasoning underneath. These are silent failures: the query runs and returns a wrong-but-plausible answer, which is the dangerous kind.

# Symptom Root cause Confirm Fix
1 Join returns fewer rows than expected; 1:many collapsed to 1:1 Default innerunique de-duped the left key Change to kind=inner and compare counts Specify kind=inner (or the outer kind you meant)
2 Time chart has phantom spikes/gaps that aren’t real bin() leaves empty buckets absent; autoscale exaggerates Re-run with make-series … default=0 and compare Use make-series … default=0 for series math/charts
3 Query scans hundreds of GB / is very slow No TimeGenerated filter, or union * unbounded Check Stats pane “data scanned”; look for missing time filter Add a tight time filter; name tables explicitly
4 where on a URL/message is slow on a hot table contains (unindexed substring scan) Swap for has; compare runtime in Stats Use has / startswith / == on term data
5 Parsed field is empty for many rows parse non-match sets fields to empty silently where isnotempty(Field) count vs total Guard with isnotempty; use parse-where; fix pattern
6 Comparing a JSON field gives wrong results Uncast dynamic leaf compared to a typed value getschema; check the leaf type Cast: toint(props.x), tostring(props.y)
7 mv-expand query is huge/slow/OOM Array expanded before filtering Move where above mv-expand Filter first, expand last
8 dcount disagrees with a known exact count dcount is approximate (HyperLogLog) Compare to count(distinct) on a small set Accept ~1–2% error, or use exact distinct when small
9 Alert never fires despite real breaches Query returns 0 rows for the wrong reason (bad filter/parse) Run the alert query manually over the window Fix the filter/parse; test the query returns rows
10 Alert fires constantly (flapping) Window/threshold/aggregation mismatch Inspect windowSize vs evaluationFrequency Widen window; raise threshold; smooth with a longer bin
11 “Latest per key” returns partial columns arg_max without * or with too few columns Check the arg_max argument list arg_max(TimeGenerated, *) for the whole row
12 Sub-result computed twice; query slow Plain let re-evaluated on each reference Note the sub-result used on both join sides Wrap the sub-result in materialize()
13 Cross-workspace query returns nothing Referenced a table in another workspace as if local Confirm the table’s workspace Use workspace("name").Table
14 Basic Logs query errors or costs a lot Used an unsupported operator / scanned a wide window Check the table’s plan (table show) Narrow the window; move the table to Analytics if you query it often

The expanded reasoning for the ones that bite hardest:

1. The join undercounts. The default join is innerunique, which de-duplicates the left table’s join key before matching — so if an operation had three exceptions, an AppRequestsAppExceptions join keeps one, not three. Confirm: re-run with kind=inner; if the count jumps, innerunique was collapsing it. Fix: always name the kindinner for relational, leftouter to keep unmatched left rows.

2. Phantom spikes on a time chart. summarize count() by bin(TimeGenerated, 1m) produces no row for a minute with zero events, so gaps are absent, not zero. The portal’s autoscale then draws a lone real point as a dramatic tower. Confirm: the same query as make-series … default=0 shows the true, gap-filled shape. Fix: use make-series … default=0 for any chart where zeros matter and for every series_* input.

3. The query scanned a terabyte. The two culprits are a missing TimeGenerated filter and an unbounded union * (or workspace-wide search). Confirm: the Stats pane’s “data scanned” is huge relative to the answer. Fix: a tight time filter first, always; name tables explicitly instead of union *.

5. Half the parsed rows are blank. parse … with sets the target fields to empty on a non-matching line — no error. Aggregating then counts blanks as a real group. Confirm: summarize Blank = countif(isempty(Field)), Total = count(). Fix: guard with where isnotempty(Field), or use parse-where to drop non-matches, or fix the pattern (a stray space breaks it).

9. The alert that never pages. A scheduled query rule fires when its query returns rows crossing the threshold; if a bad filter or a silently-failing parse makes the query return zero rows, the alert simply never fires — you find out during the incident it was supposed to catch. Confirm: run the exact alert query over its window manually and check it returns the rows you expect during a known-bad period. Fix: test alert queries against historical incidents; assert they return non-empty; alert on the absence of expected data where appropriate.

Best practices

Security notes

The security controls that also improve query hygiene:

Control Mechanism Protects against Also improves
Resource-context RBAC Access mode + resource permissions Over-broad data exposure Scoped, faster per-tenant queries
Table-level RBAC Per-table role assignments PII/security-table exposure Cleaner separation of concerns
DCR transform redaction transformKql project-away/hash Secrets/PII at rest Lower ingestion volume/cost
Watchlists over inline lists Sentinel watchlist + RBAC Sensitive lists sprayed in queries Central, reusable lookups
Query-as-code review Functions/detections in Git Unreviewed, leaky queries Consistent, tested logic

Cost & sizing

KQL cost has two halves, and both are controllable:

# What is each table costing? (the query to run weekly)
az monitor log-analytics query --workspace "$WSID" \
  --analytics-query "Usage | where TimeGenerated > ago(7d) | summarize GB = sum(Quantity)/1024 by DataType | sort by GB desc" \
  -o table

# Set a daily cap (GB) as a backstop, and a commitment tier via the workspace SKU
az monitor log-analytics workspace update \
  --resource-group $RG --workspace-name $WS --quota 200   # daily cap in GB

The cost levers, coarsest to finest, and what each buys:

Lever Where it applies Effect Watch-out
DCR transformKql (drop/strip) Ingestion pipeline Removes bytes you never pay to store Analytics transforms bill on output — use project-away, not extend
Table plan (Basic/Auxiliary) Per table Cheaper ingestion for rarely-queried data Loses alerting/some join; per-query scan cost
Commitment (capacity) tier Workspace Per-GB discount over pay-as-you-go Size to the floor; overage still bills
Daily cap Workspace Hard backstop against runaway ingestion Drops data past the cap
Interactive retention Per table Shorter retention = lower cost Balance against investigation needs
Query hygiene (time filter, has, materialize) Every query Less scanned; faster; cheaper on Basic The habit that compounds

A rough monthly picture: a workspace ingesting ~500 GB/day on Analytics runs into the low lakhs of INR/month before optimization; moving 30–40% of that volume to Basic Logs and dropping DEBUG at ingestion typically reclaims the first ₹2–3 lakh, and a commitment tier sized to the new floor shaves more. As with Meridian, the fix is usually where the data lands, not a bigger SKU.

Interview & exam questions

1. What is the difference between summarize by bin() and make-series, and when must you use each? bin() inside summarize produces a table with one row per non-empty time bucket — empty buckets are absent. make-series … default=0 produces gap-filled vector series (value + time arrays). You must use make-series before any series_* function (anomaly detection, forecasting) and for any math across buckets, because those need regular, gap-filled input; bin is fine for charts and tables where absent buckets don’t distort the answer.

2. Why does the default KQL join sometimes undercount, and how do you fix it? The default kind is innerunique, which de-duplicates the left table’s join key before matching. A genuine one-to-many relationship collapses to one-to-one and undercounts. Fix by naming the kind explicitly — kind=inner for a relational inner join, or the appropriate outer kind — and by pre-aggregating before the join when you can.

3. Name the two highest-leverage KQL performance rules and why they work. (1) Filter on TimeGenerated first — the columnstore is time-partitioned, so a tight window prunes whole partitions before any read. (2) project early and narrow — the store is per-column-compressed, so reading 5 columns instead of 40 reads a fraction of the bytes. Both are corollaries of “help the engine skip data.”

4. When do you use hint.strategy=shuffle versus hint.strategy=broadcast? broadcast when one side of a join is small (roughly under 100k rows) — ship it to every node. shuffle when both sides are large and the join key is high-cardinality — repartition both by the key so each node handles a slice, avoiding a single-node memory blow-up. Don’t shuffle a low-cardinality key (it over-partitions).

5. How do you get the whole latest record per entity? summarize arg_max(TimeGenerated, *) by EntityId. arg_max returns the full row (* = all columns) at the maximum of the first argument — the canonical “current state per entity.” arg_min gives the earliest. Using max(TimeGenerated) alone gives only the timestamp, not the row.

6. Why can a parse silently corrupt an aggregate, and how do you guard against it? parse … with sets its target fields to empty (not an error) when a line doesn’t match the pattern, so aggregating counts blanks as a real group. Guard with where isnotempty(Field) after the parse, use parse-where to drop non-matches, or tighten the pattern. Always validate the match rate on real data.

7. What is the risk of mv-expand, and where should it go in a pipeline? mv-expand fans an array to one row per element, multiplying rows — a 10-element array becomes 10 rows. Placed before filters it explodes the working set (slow, possibly OOM). Put it after your where clauses, expanding only the rows you actually need. Use mv-apply when you need a sub-query per array.

8. When should a table be on Basic Logs instead of Analytics, and what do you give up? Basic Logs suits high-volume, rarely-queried data (verbose app/firewall/container logs) — it has cheaper ingestion but bills per GB scanned at query time and offers reduced KQL (limited operators, no alerting on the table, short interactive retention). You give up interactive dashboards and alerts on that table; you keep cheap storage and occasional investigation.

9. What does materialize() do and when is it worth it? It computes a tabular sub-expression once, caches the result for the query’s lifetime, and reuses it on every reference — turning N scans into one. It is worth it whenever a let-bound sub-result is referenced more than once (both join sides, fan-outs, unions of aggregates). The cost is memory, so materialize a filtered, narrowed sub-result, not a raw table.

10. How does RBAC access mode change what a KQL query returns? Under resource-context access, a user sees only rows for resources they have permission on, so the same query returns different results per identity. Under workspace-context, they see all rows in the workspace. This affects dashboards, alerts, and Sentinel queries — design for it and grant the narrowest scope that works.

11. Why might a perfectly valid alert query never fire? A scheduled query rule fires when its query returns rows crossing the threshold. If a bad filter, a silently-failing parse, or a wrong join kind makes the query return zero rows, the rule never fires — you discover it during the incident it was meant to catch. Test alert queries against historical bad windows and assert they return the expected rows.

12. What is the cheapest way to detect hosts that stopped reporting? A leftanti join: distinct Computer over the last hour, join kind=leftanti against distinct Computer over the last 15 minutes — what survives is present-then-absent. It cleanly replaces awkward NOT-IN logic and is the canonical silent-host detection.

These map to AZ-104 (Administrator)monitor resources with Azure Monitor, configure Log Analytics — and AZ-204 (Developer)instrument and monitor solutions, Application Insights, KQL. The security/hunting angle (Sentinel detections, ASIM, watchlists, UEBA) maps to SC-200 (Security Operations Analyst), and the data-platform depth touches DP-203 where Azure Data Explorer appears. A compact cert map:

Question theme Primary cert Objective area
bin vs make-series, series functions AZ-104 / AZ-204 Monitor & analyze with Log Analytics
Join kinds, performance rules AZ-204 Instrument, query, troubleshoot
Table plans, cost, commitment tiers AZ-104 Configure & manage Azure Monitor
RBAC access modes, table-level RBAC AZ-104 / SC-200 Secure monitoring data
Sentinel detections, ASIM, watchlists SC-200 Threat detection with KQL
Resource Graph KQL subset AZ-104 / AZ-500 Governance & inventory at scale

Quick check

  1. Your latency time chart shows a dramatic 900 ms tower at 03:14 but no matching errors or deploy. What is the single most likely query cause, and how do you confirm it?
  2. A join between AppRequests and AppExceptions returns fewer failures than customers report. What is the default doing, and what one keyword fixes it?
  3. True or false: dcount(UserId) returns an exact distinct count.
  4. You need the whole most-recent row per Computer. Which single summarize expression gives it?
  5. Your Log Analytics query scanned 300 GB to answer a question about the last hour. Name the two most likely causes.

Answers

  1. bin() leaving empty buckets absent. A low-traffic series with one slow request in an otherwise-empty minute is drawn by the portal’s autoscale as a tower, because bin produces no row for the empty minutes around it. Confirm by re-running as make-series … default=0; the gap-filled series shows the true (undramatic) shape.
  2. The default innerunique de-duplicates the left (request) key before matching, collapsing one-to-many to one-to-one and undercounting. Fix by specifying kind=inner (or the outer kind you actually mean).
  3. False. dcount is an approximate distinct count using HyperLogLog — fast and memory-bounded on high cardinality, with a small (~1–2%) error. Use exact distinct only when the value set is small enough to afford it.
  4. summarize arg_max(TimeGenerated, *) by Computerarg_max returns the full row (*) at the maximum TimeGenerated, i.e. the current state per host. max(TimeGenerated) alone gives only the timestamp.
  5. (a) A missing or too-wide TimeGenerated filter, so the engine can’t prune partitions; and/or (b) an unbounded union * / workspace-wide search that touches every table. Add a tight time filter and name tables explicitly; check the Stats pane’s “data scanned.”

Glossary

Next steps

You can now write KQL that is both correct and cheap. Build outward:

AzureMonitorKQLLogAnalyticsKustoSentinelAzureObservabilityCostOptimization
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading