Common KQL Operators and Functions
This page walks through the operators in roughly the order you use them when building a query: filter rows, shape columns, sort and limit, aggregate, combine tables, and work with dynamic, date, and string values.
Note
The examples on this page use SigninLogs (a Log Analytics / Sentinel table with a TimeGenerated column) unless noted otherwise. In Microsoft 365 Defender Advanced Hunting the equivalent tables use a Timestamp column instead. See Where KQL runs and why schemas differ.
Filtering rows with where
The where operator keeps only the rows that match a predicate. Putting where early in the pipeline - especially a time filter - is the most important thing you can do for query performance, because it reduces how much data every later step has to process.
SigninLogs
| where TimeGenerated > ago(1d)
| where ResultType != "0"
| where AppDisplayName == "Microsoft Teams"
String and comparison operators
| Operator | Meaning | Notes |
|---|---|---|
== / != |
Equals / not equals | Case-sensitive for strings; use =~ / !~ for case-insensitive |
has / !has |
Whole-term match | Indexed and fast - prefer this for word matches |
contains / !contains |
Substring match | Not indexed and slower - only when you truly need a partial match |
startswith / endswith |
Prefix / suffix match | Case-insensitive; _cs variants are case-sensitive |
in / !in |
Value in a set | Case-sensitive; use in~ / !in~ for case-insensitive |
has_any / has_all |
Matches any / all terms in a list | Useful for keyword lists |
matches regex |
Regular-expression match | Powerful but slower than has |
between |
Value within a range | Works for numbers and datetimes: between (datetime(2025-01-01) .. datetime(2025-01-31)) |
Tip
Prefer has over contains whenever you are matching whole words. has uses the string index and scans far less data; contains inspects every character of every value.
Shaping columns with project and extend
Use these operators to control which columns flow down the pipeline and to add calculated columns.
project- Select the columns to keep, in the order listed. Also used to compute new columns.extend- Add calculated columns without removing the existing ones.project-away- Remove specific columns, keeping everything else.project-keep- Keep only columns matching the given names or patterns.project-rename- Rename a column.
SigninLogs
| where TimeGenerated > ago(1d)
| extend Country = tostring(LocationDetails.countryOrRegion)
| project-rename SignInTime = TimeGenerated
| project SignInTime, UserPrincipalName, IPAddress, Country
Tip
Reach for project early to drop columns you do not need, and use extend when you only want to add a computed column while keeping the rest of the row intact.
Sorting, limiting, and de-duplicating
| Operator | Purpose |
|---|---|
sort by <col> [asc\|desc] |
Order rows by one or more columns (order by is a synonym) |
top <N> by <col> |
Return the N rows with the highest/lowest value of a column |
take <N> / limit <N> |
Return N arbitrary rows - useful for quickly sampling a table |
distinct <cols> |
Return the unique combinations of the listed columns |
count |
Return the number of rows as a single value |
SigninLogs
| where TimeGenerated > ago(1d)
| where ResultType != "0"
| summarize FailedAttempts = count() by UserPrincipalName
| top 10 by FailedAttempts desc
Tip
top N by <col> is more efficient than sort followed by take, because the engine only has to track the top N rows rather than order the entire result set.
Aggregating data with summarize
The KQL operator is summarize (not summary). It groups rows into buckets and calculates aggregate values for each group. This is the primary way to roll up logs into counts, totals, averages, or sets.
SigninLogs
| where TimeGenerated > ago(7d)
| summarize
TotalAttempts = count(),
FailedAttempts = countif(ResultType != "0"),
DistinctUsers = dcount(UserPrincipalName),
DistinctIPs = dcount(IPAddress),
Countries = make_set(tostring(LocationDetails.countryOrRegion))
by bin(TimeGenerated, 1h), AppDisplayName
The aggregation functions below also include numeric rollups such as avg(), sum(), min(), and max() for tables that carry numeric measures (for example Perf or AzureMetrics).
Common aggregation functions include:
count()- Counts the number of rows in each group.countif(predicate)- Counts rows where the predicate evaluates totrue.sum(column)- Adds numeric values across the group.avg(column)- Calculates the average value.min(column)andmax(column)- Return the smallest or largest value.dcount(column)- Returns the approximate distinct count for a column.make_set(column)andmake_list(column)- Collect values into an array.arg_min(column, ...)andarg_max(column, ...)- Return other columns from the row with the smallest/largest value.
Use the by clause to define grouping keys, and bin() when you want time-based buckets such as hourly or daily summaries.
EmailEvents
| where Timestamp > ago(1d)
| summarize MessagesSent = count(), PhishDetected = countif(ThreatTypes has "Phish") by bin(Timestamp, 1h), SenderFromAddress
| order by Timestamp desc
Combining tables with union and join
union
union concatenates the rows of two or more tables that have compatible schemas. Use withsource to record which table each row came from.
union withsource = SourceTable SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(1d)
| summarize Events = count() by SourceTable
join
join merges rows from two tables on a matching key, similar to a SQL join.
let FailedSignins =
SigninLogs
| where TimeGenerated > ago(1d)
| where ResultType != "0"
| distinct UserPrincipalName;
SigninLogs
| where TimeGenerated > ago(1d)
| where ResultType == "0"
| join kind=inner FailedSignins on UserPrincipalName
| distinct UserPrincipalName
Important
The default join kind is innerunique, not inner. innerunique first de-duplicates the left table on the join key, which can silently drop rows you expected to see. Always state the kind explicitly (kind=inner, kind=leftouter, and so on) so the result is predictable.
Common join kinds:
inner- Rows with a matching key in both tables (keeps all matches).innerunique- The default; de-duplicates the left side on the key first.leftouter- All rows from the left table, with matches from the right where they exist.leftsemi/leftanti- Left rows that do / do not have a match on the right (the right table's columns are not returned).
Working with dynamic and JSON data
Parse JSON into columns
The parse_json() function extracts values from JSON strings. This is particularly common in log data that stores complex payloads as strings. (todynamic() is a synonym.)
extend SPF_ = parse_json(AuthenticationDetails).SPF
Example - Extracting email authentication details:
EmailEvents
| where RecipientEmailAddress endswith "gmail.com"
| extend SPF_ = parse_json(AuthenticationDetails).SPF
| extend DKIM_ = parse_json(AuthenticationDetails).DKIM
| extend DMARC_ = parse_json(AuthenticationDetails).DMARC
| project Timestamp, RecipientEmailAddress, SenderFromAddress, SenderIPv4, SenderIPv6, SPF_, DMARC_, DKIM_, LatestDeliveryAction
Tip
Values pulled out of a dynamic object keep the dynamic type. Wrap them in tostring(), toint(), or a similar conversion function before comparing, sorting, or joining on them.
mv-expand operator
The mv-expand operator expands multi-value dynamic arrays or property bags into multiple records.
Important
mv-expand is essential when working with arrays in KQL. It transforms one row with an array into multiple rows, one for each array element.
Example - Expanding conditional access policies:
Each sign-in event stores its applied conditional access policies as an array. Use mv-expand to produce one row per policy, then pull individual fields out of each element.
SigninLogs
| where TimeGenerated > ago(1d)
| mv-expand ConditionalAccessPolicies
| extend PolicyName = tostring(ConditionalAccessPolicies.displayName)
| extend PolicyResult = tostring(ConditionalAccessPolicies.result)
| project TimeGenerated, UserPrincipalName, PolicyName, PolicyResult
Converting data types
Log fields are often stored as strings or dynamic values, so explicit conversion is frequently required before comparison or arithmetic.
| Function | Converts to |
|---|---|
tostring(x) |
String |
toint(x) / tolong(x) |
32-bit / 64-bit integer |
toreal(x) |
Floating-point number |
tobool(x) |
Boolean |
todatetime(x) |
Datetime |
totimespan(x) |
Timespan |
todynamic(x) / parse_json(x) |
Dynamic (JSON) object |
SigninLogs
| extend City = tostring(LocationDetails.city)
| extend RiskLevel = toint(RiskLevelDuringSignIn)
Working with dates and times
Datetime and timespan handling underpins nearly every query. Timespan literals combine a number with a unit: 1d (day), 1h (hour), 30m (minutes), 5s (seconds).
| Function | Purpose |
|---|---|
ago(timespan) |
A time relative to now, e.g. ago(7d) for seven days ago |
now() |
The current UTC time |
bin(value, roundTo) |
Round a value down to a bucket, e.g. bin(TimeGenerated, 1h) |
startofday() / startofweek() / startofmonth() |
Truncate a datetime to the start of the period |
format_datetime(dt, format) |
Format a datetime as a string |
datetime_diff('unit', a, b) |
Difference between two datetimes in the given unit |
SigninLogs
| where TimeGenerated > ago(30d)
| summarize DailySignins = count() by Day = startofday(TimeGenerated)
| order by Day asc
Note
KQL datetimes are always in UTC. If you need local time, convert explicitly - the query engine does not apply a time zone for you.
Conditionals and string functions
Conditional logic
iff(condition, ifTrue, ifFalse)- Returns one of two values based on a condition (iifis a synonym).case(pred1, val1, pred2, val2, ..., default)- Multi-branch conditional; returns the value for the first matching predicate.
SigninLogs
| extend SignInResult = iff(ResultType == "0", "Success", "Failure")
| extend Severity = case(
RiskLevelDuringSignIn == "high", "Investigate",
RiskLevelDuringSignIn == "medium", "Monitor",
"Normal")
String manipulation
strcat(a, b, ...)- Concatenate strings;strcat_delim(delim, ...)joins with a separator.substring(source, start, length)- Extract part of a string.split(source, delimiter)- Split a string into an array.extract(regex, captureGroup, source)- Pull a value out of a string with a regular expression.replace_string(source, lookup, rewrite)- Replace occurrences of a substring.toupper()/tolower()/trim()- Change case or strip characters.
SigninLogs
| extend Domain = tostring(split(UserPrincipalName, "@")[1])
| summarize Signins = count() by Domain
| order by Signins desc
Reference links
- where operator - Microsoft Documentation
- project operator - Microsoft Documentation
- join operator - Microsoft Documentation
- summarize operator - Microsoft Documentation
- countif() (aggregation function) - Microsoft Documentation
- aggregation functions - Microsoft Documentation
- mv-expand operator - Microsoft Documentation
- Scalar data types - Microsoft Documentation
- String operators - Microsoft Documentation