Using mv-apply in KQL.
The array was full of evidence.
But expanding every value created too much noise.
Agent Foskett did not need every item.
He needed controlled expansion, filtering and analysis inside the multi-value field itself.
Lesson overview
Learn how defenders use mv-apply to analyse arrays and dynamic values with more control than mv-expand, helping Microsoft Defender XDR investigations filter, summarise and extract only the evidence that matters.
Why mv-apply matters
The mv-apply investigation workflow
Step 1 — Basic mv-apply pattern
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
DeviceEvents
| where Timestamp > ago(24h)
| where isnotempty(AdditionalFields)
| extend Details = parse_json(AdditionalFields)
| extend Indicators = Details.Indicators
| mv-apply Indicator = Indicators on (
project IndicatorValue = tostring(Indicator.Value),
IndicatorType = tostring(Indicator.Type)
)
| project Timestamp,
DeviceName,
ActionType,
IndicatorType,
IndicatorValue
| order by Timestamp descStep 2 — Filter values inside mv-apply
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
DeviceEvents
| where Timestamp > ago(7d)
| extend Details = parse_json(AdditionalFields)
| extend Indicators = Details.Indicators
| mv-apply Indicator = Indicators on (
extend IndicatorType = tostring(Indicator.Type)
| extend IndicatorValue = tostring(Indicator.Value)
| where IndicatorType in ("Url", "Domain", "IpAddress")
| project IndicatorType, IndicatorValue
)
| project Timestamp,
DeviceName,
ActionType,
IndicatorType,
IndicatorValueStep 3 — Analyse suspicious URLs
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
DeviceEvents
| where Timestamp > ago(7d)
| extend Details = parse_json(AdditionalFields)
| extend Urls = Details.Urls
| mv-apply UrlItem = Urls on (
extend Url = tostring(UrlItem)
| where Url has_any (".zip", ".top", ".xyz", "login", "verify")
| project Url
)
| project Timestamp,
DeviceName,
ActionType,
Url
| order by Timestamp descStep 4 — Summarise values returned from arrays
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
DeviceEvents
| where Timestamp > ago(7d)
| extend Details = parse_json(AdditionalFields)
| extend Indicators = Details.Indicators
| mv-apply Indicator = Indicators on (
extend IndicatorValue = tostring(Indicator.Value)
| where isnotempty(IndicatorValue)
| project IndicatorValue
)
| summarize IndicatorCount = dcount(IndicatorValue),
IndicatorSet = make_set(IndicatorValue, 25)
by DeviceName,
ActionType
| order by IndicatorCount descStep 5 — Compare mv-expand and mv-apply
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
// mv-expand: expand everything, then filter
DeviceEvents
| where Timestamp > ago(24h)
| extend Details = parse_json(AdditionalFields)
| extend Indicators = Details.Indicators
| mv-expand Indicators
| extend IndicatorValue = tostring(Indicators.Value)
| where IndicatorValue has "malicious"
| project Timestamp, DeviceName, IndicatorValue;
// mv-apply: filter inside the array subquery
DeviceEvents
| where Timestamp > ago(24h)
| extend Details = parse_json(AdditionalFields)
| extend Indicators = Details.Indicators
| mv-apply Indicator = Indicators on (
extend IndicatorValue = tostring(Indicator.Value)
| where IndicatorValue has "malicious"
| project IndicatorValue
)
| project Timestamp, DeviceName, IndicatorValueStep 6 — Controlled IOC investigation
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
let KnownBadIndicators = dynamic([
"evil.example",
"bad-domain.top",
"203.0.113.10",
"malicious-file.zip"
]);
DeviceEvents
| where Timestamp > ago(14d)
| extend Details = parse_json(AdditionalFields)
| extend Indicators = Details.Indicators
| mv-apply Indicator = Indicators on (
extend IndicatorValue = tostring(Indicator.Value)
| where IndicatorValue in~ (KnownBadIndicators)
| project IndicatorValue
)
| project Timestamp,
DeviceName,
ActionType,
AccountName,
IndicatorValue
| order by Timestamp descWhen to use mv-expand versus mv-apply
Real-world investigation
Investigation checklist
Related Agent Foskett Academy lessons
Coming next
Final thought
Using mv-apply in KQL
Agent Foskett Academy Lesson 98 teaches defenders how to use mv-apply in KQL to analyse arrays, dynamic fields and multi-value evidence during Microsoft Defender XDR investigations.
Learn mv-apply for Microsoft Defender XDR
The KQL mv-apply operator helps Microsoft security analysts filter, project and summarise values inside arrays and dynamic objects without expanding unnecessary evidence.
KQL mv-apply tutorial for Microsoft security analysts
This lesson explains mv-apply, mv-expand comparison, dynamic arrays, nested objects, IOC matching and controlled threat hunting patterns for Microsoft Defender XDR telemetry.
