6.1 Construct KQL Statements for Microsoft Sentinel

8-12 hours · Module 6 · Free

Construct KQL Statements for Microsoft Sentinel

SC-200 Exam Objective

Domain 4 — Manage Security Threats: "Identify threats by using Kusto Query Language (KQL)" and "Create custom hunting queries by using KQL." This subsection builds the foundation for every KQL exam question.

Introduction

This subsection teaches you the fundamental building blocks of KQL — the operators you will use in every query you write for the rest of this course and your career as a SOC analyst.

KQL (Kusto Query Language) was designed by Microsoft for querying large volumes of structured log data. It is the query language behind Microsoft Sentinel, Microsoft Defender XDR Advanced Hunting, Azure Monitor, and Azure Data Explorer. If you have used SQL before, KQL will feel partially familiar — both query tabular data — but KQL is read-only (you cannot insert, update, or delete data) and its syntax flows differently.

If you have never used a query language before, that is fine. This subsection assumes no prior query experience. You will write your first query within the next few minutes, and by the end of this subsection you will construct a complete investigation query that traces a brute-force attack from first failure to successful compromise.

Here is what you will learn in this subsection, in order:

  • The pipe model — how KQL processes data step by step, and why understanding this model is the key to writing any query
  • The where operator — filtering rows to find the data relevant to your investigation, with every comparison operator you need for security work
  • The project operator — controlling which columns appear in your output, and why column order matters
  • The extend operator — adding new calculated columns that transform raw log data into investigation-ready information
  • The let statement — naming values and sub-queries for clarity and reuse, including a critical detection pattern you will use in Module 9
  • The order by operator — sorting results for timeline reconstruction
  • The search operator — finding data when you do not know which table contains it
  • A complete investigation query — built step by step from a realistic alert scenario, combining every operator into a single query that tells the story of an attack

Each operator is introduced with its purpose, taught through a security investigation example, and practiced in your lab environment. You will run every query yourself, see the output, and understand what it means before moving to the next operator.

Open your Sentinel workspace now (portal.azure.com → Microsoft Sentinel → Logs) and keep it alongside this page. You will use it throughout.


THE KQL PIPE MODEL — DATA FLOWS LEFT TO RIGHTSigninLogs2.4M rows|whereTimeGenerated > ago(24h)|whereResultType != "0"|project7 columnsResults47 rows

Start wideFilter downShape outputFocused answer

Figure 6.1: The KQL pipe model. Data starts at the table (2.4 million rows), each pipe operator transforms it (filter, shape), and the final output is a focused answer (47 rows). Every KQL query follows this left-to-right flow.

The pipe model — how KQL thinks

Every KQL query follows the same pattern: start with a table, then transform the data step by step using the pipe character (|). Each step takes the output of the previous step and does something to it — filtering, shaping, computing, or sorting.

Think of it like a funnel:

  1. Start wide — select a table with millions of rows
  2. Filter down — keep only the rows that matter
  3. Shape the output — choose which columns to display
  4. Summarize or sort — organize the results for analysis

Here is the simplest possible KQL query:

1
SigninLogs

This returns every row in the SigninLogs table. In a production environment with 500 users, that could be hundreds of thousands of rows for a single day. It is technically valid KQL, but useless for investigation — you would drown in data.

Now add the pipe to start filtering:

1
2
SigninLogs
| where TimeGenerated > ago(1h)

This says: “Start with everything in SigninLogs. Then, keep only the rows where the timestamp is within the last hour.” The pipe (|) means “take the output from the line above and pass it to the next operation.”

SENTINEL LOGS — KQL QUERY EDITOR▶ RunTime range: Last 24 hours ▾SigninLogs|whereTimeGenerated >ago(24h)|whereResultType !="0"|projectTimeGenerated, UserPrincipalName, IPAddress, ResultDescription1234Results | Chart | Query statistics47 rowsTimeGenerated UserPrincipalName IPAddress ResultDescription
Figure 6.2: The Sentinel Logs query editor. Type your KQL in the editor (dark area), click Run, and results appear below. Line numbers on the left, syntax highlighting shows operators in blue and strings in orange. This is where you will write every query in this module.

Run this in your lab environment now. Paste the query into the Sentinel Logs editor and click Run.

Expected Output
TimeGeneratedUserPrincipalNameIPAddressResultTypeLocationAppDisplayName...
14:32:01j.morrison@yourdomain.onmicrosoft.com86.12.45.890GBMicrosoft Office...
14:28:44s.patel@yourdomain.onmicrosoft.com86.12.45.890GBAzure Portal...
What to look for: Each row is a sign-in event. ResultType = 0 means success. Your lab will show the sign-ins you generated in Module 0.6. Notice how many columns the table returns — SigninLogs has over 50 columns. Most are irrelevant to any given investigation. You will learn to control which columns appear using project later in this subsection.

The key mental model: every line in a KQL query transforms the data from the line above. The table name is your starting point. Every pipe adds a transformation. The final output is what appears in your results pane. This pipe-by-pipe flow is how you should read, write, and debug every KQL query.


The where operator — filtering to what matters

What it is

where evaluates a condition against every row and keeps only the rows where the condition is true. Everything else is discarded. It is the most frequently used operator in security KQL — nearly every query starts with one or more where clauses that narrow millions of rows down to the handful relevant to your investigation.

Why it matters

A production Sentinel workspace ingests tens of millions of events per day. Without where, every query returns all of them. With where, you ask precise questions: “Show me failed sign-ins from outside our network in the last hour.” Each where clause narrows the dataset further, like adding filters to a camera lens until only the subject is in focus.

How it works

Syntax: | where ColumnName Operator Value

Start with the query you already ran, and add a filter for failed sign-ins:

1
2
3
SigninLogs
| where TimeGenerated > ago(24h)
| where ResultType != "0"

Run this now. You are looking at failed sign-ins in the last 24 hours. ResultType != "0" means “not successful” — every non-zero ResultType code represents a specific failure reason.

Expected Output
TimeGeneratedUserPrincipalNameIPAddressResultTypeResultDescription
13:41:22j.morrison@yourdomain.onmicrosoft.com198.51.100.4450126Invalid username or password
13:41:19j.morrison@yourdomain.onmicrosoft.com198.51.100.4450126Invalid username or password
What to look for: Multiple failed sign-ins from the same IP targeting the same user in quick succession. This is the signature of a brute-force attack or credential stuffing. ResultType 50126 specifically means wrong password. In your lab, you may not see failures unless you deliberately entered a wrong password during Module 0.6 setup — that is expected.

You now have a filter chain: all sign-ins → last 24 hours → failures only. Each where reduces the dataset further.

CHOOSE THE RIGHT OPERATOR — DECISION GUIDEExact value?== or !=ResultType == "0"Whole word search?has ⚡ FASTUPN has "morrison"Substring match?contains 🐌 SLOWDescription contains "err"List?in / !inRT in ("50126"..)Range?betweenTime between(..)
Figure 6.3: Operator selection guide. Start with the question: what kind of match do you need? Default to has for text searches (fast, indexed). Use contains only when you need partial-word matching.

The comparison operators for security work

These are the operators you will use inside where clauses. You do not need to memorize this table — you will internalize them through use. But reference it when you need an operator and cannot remember the syntax.

OperatorMeaningExampleWhen to use it
==Equals (case-sensitive)where ResultType == "0"Exact match on a known value
!=Not equalswhere ResultType != "0"Exclude a known value
>, <, >=, <=Greater/less thanwhere TimeGenerated > ago(1h)Time ranges, numeric comparisons
hasContains whole term (case-insensitive, indexed)where UserPrincipalName has "morrison"Find a user by partial name — fast
containsContains substring (case-insensitive, not indexed)where ResultDescription contains "password"Search within free-text fields — slow
startswithBegins withwhere IPAddress startswith "198.51"Filter by IP range prefix
inMatches any value in a listwhere ResultType in ("50126", "50053")Check against multiple known values
!inDoes NOT match any valuewhere AppDisplayName !in ("Microsoft Office", "Azure Portal")Exclude a list of known-good values
betweenWithin a rangewhere TimeGenerated between (datetime(2026-03-20) .. datetime(2026-03-21))Precise time window during an incident
has vs contains — this matters more than you think

has searches for whole terms using the table's built-in index. It is fast — milliseconds on a table with millions of rows. contains searches for any substring match without using an index. It is slow — seconds or minutes on the same table. In almost every security investigation, has gives the same result as contains. Use has by default. Switch to contains only when you need to match a partial word inside a larger string (rare). This is one of the most impactful performance habits you can build early.

Combining conditions

Use and and or to build compound filters. You can chain where clauses on separate lines (implicit and) or combine conditions in a single line.

These two queries are identical:

1
2
3
4
5
6
7
8
9
// Method 1: separate where clauses (cleaner, easier to read)
SigninLogs
| where TimeGenerated > ago(24h)
| where ResultType != "0"
| where IPAddress !startswith "86.12"

// Method 2: combined with 'and' (same result, one line)
SigninLogs
| where TimeGenerated > ago(24h) and ResultType != "0" and not(IPAddress startswith "86.12")

Method 1 is preferred for readability — each line states one filter condition, making the query easier to understand and debug. During an active investigation, readable queries save time.

The third filter (IPAddress !startswith "86.12") excludes your office network (assuming 86.12.x.x is your known IP range). Now you are filtering out the noise — your own users who mistyped passwords — and focusing on external failed sign-in attempts.

Try it yourself — build it before you read on

Write a query that finds successful sign-ins (ResultType == "0") in the last 7 days where the user principal name contains "morrison" and the application is NOT "Microsoft Office" and NOT "Azure Portal". Think about which operator to use for the username search and which to use for the application exclusion.
1
2
3
4
5
SigninLogs
| where TimeGenerated > ago(7d)
| where ResultType == "0"
| where UserPrincipalName has "morrison"
| where AppDisplayName !in ("Microsoft Office", "Azure Portal")

Why this query matters for investigation: If j.morrison’s account is compromised, the attacker will access applications beyond the user’s normal pattern. Filtering out expected apps (Office, Azure Portal) reveals unexpected access — the applications the attacker is targeting. This pattern appears in Module 11 (AiTM investigation) when identifying post-compromise activity.

Note the operator choices: has for the username (fast indexed search for a whole term). !in for the application exclusion (check against a list of known-good values). These choices are deliberate — and choosing the right operator becomes instinct with practice.


THE PROJECT RULE — FILTER FIRST, COMPUTE SECOND, PROJECT LAST1. where (filter)2. extend (compute)3. project (shape)4. order by (sort)
Figure 6.4: The standard query structure. Filter with where first (reduces rows), compute with extend second (adds columns), shape with project third (selects columns), sort with order by last. Placing project before where removes columns you need for filtering.

The project operator — controlling what you see

What it is

project selects which columns appear in your query output and discards the rest. It is the KQL equivalent of choosing which columns to display in a spreadsheet.

Why it matters

SigninLogs has over 50 columns. During an investigation, you need 5-8 of them. Without project, your results pane is overwhelmed with irrelevant data — authentication protocols, resource IDs, correlation IDs — none of which help you answer “who signed in from where and did it succeed?” project strips the noise and lets you focus on the columns that answer your investigation question.

How it works

Add project to the query you have been building:

1
2
3
4
5
SigninLogs
| where TimeGenerated > ago(24h)
| where ResultType != "0"
| project TimeGenerated, UserPrincipalName, IPAddress, ResultType,
    ResultDescription, Location, AppDisplayName

Run this. Your results now show exactly the columns relevant to a failed sign-in investigation — when it happened, who was targeted, from what IP, why it failed, what country, and which application. The 40+ irrelevant columns are gone. The data is immediately readable.

The project variants

VariantWhat it doesWhen to use it
projectKeep ONLY these columnsDefault choice — you know exactly which columns you need
project-awayRemove these columns, keep everything elseWhen you want most columns but need to remove a few (e.g., stripping PII before sharing results)
project-renameRename a columnWhen column names are unclear: project-rename FailureReason = ResultDescription
project-reorderChange column display order without removing anyWhen you want key columns first but need access to all others
project permanently removes columns from the query pipeline

After a project statement, the columns you did not include no longer exist for subsequent operations. If you write | project TimeGenerated, UserPrincipalName on line 3, then | where IPAddress == "198.51.100.44" on line 4, the query fails — IPAddress was removed on line 3. Rule: place project at the end of the query, after all filtering and computation. Filter first, compute second, project last.


The extend operator — adding computed columns

What it is

extend adds a new column to every row based on a calculation or transformation. Unlike project, it does not remove any existing columns — it adds to them. The new column exists for all subsequent operations in the query.

Why it matters

Raw log data is factual but not always useful in its raw form. A timestamp of 2026-03-21T08:14:22Z is precise, but during an investigation you want to know “how long ago was this?” An IP address of 198.51.100.44 is specific, but you want to know “is this internal or external?” An email field of j.morrison@northgateeng.com contains useful structure, but you need just the domain. extend transforms raw data into investigation-ready context.

How it works

Add extend to compute the time since each failed sign-in:

1
2
3
4
5
SigninLogs
| where TimeGenerated > ago(24h)
| where ResultType != "0"
| extend HoursAgo = round(datetime_diff('hour', now(), TimeGenerated), 1)
| project TimeGenerated, HoursAgo, UserPrincipalName, IPAddress, ResultDescription
Expected Output
TimeGeneratedHoursAgoUserPrincipalNameIPAddressResultDescription
13:41:221.3j.morrison@...198.51.100.44Invalid username or password
08:12:056.8m.chen@...203.0.113.88Account is locked
What to look for: HoursAgo gives you an instant sense of recency without mental timestamp arithmetic. 1.3 hours = very recent, possibly still active. 6.8 hours = happened during the morning, lower urgency. During a fast-moving investigation, eliminating mental math frees attention for analysis.

extend patterns you will use in every investigation

These four patterns appear so frequently that they are worth learning now. You will use them throughout Modules 7-15.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 1. Extract the domain from an email address
| extend Domain = tostring(split(UserPrincipalName, "@")[1])

// 2. Classify an IP as internal or external
| extend NetworkZone = iff(IPAddress startswith "10." or IPAddress startswith "192.168.",
    "Internal", "External")

// 3. Extract location from nested JSON (common in SigninLogs)
| extend City = tostring(LocationDetails.city)
| extend Country = tostring(LocationDetails.countryOrRegion)

// 4. Classify time of day for anomaly detection
| extend Hour = hourofday(TimeGenerated)
| extend TimeCategory = iff(Hour >= 8 and Hour < 18, "Business Hours", "After Hours")

Each creates a new column without modifying existing data. Together, they transform raw IPAddress = 198.51.100.44 into NetworkZone = External, Country = US, TimeCategory = After Hours — which is immediately useful for investigation.

Try it yourself — build it before you read on

Write a query against SigninLogs for the last 7 days that adds two new columns: one that extracts the city from LocationDetails, and one that classifies the sign-in as "Business Hours" (8am-6pm) or "After Hours" based on the hour of the timestamp. Show only: TimeGenerated, the time category, the city, the application name, and whether it succeeded or failed. Hint: hourofday(TimeGenerated) returns the hour as an integer 0-23.
1
2
3
4
5
6
7
8
SigninLogs
| where TimeGenerated > ago(7d)
| extend City = tostring(LocationDetails.city)
| extend Hour = hourofday(TimeGenerated)
| extend TimeCategory = iff(Hour >= 8 and Hour < 18, "Business Hours", "After Hours")
| extend Outcome = iff(ResultType == "0", "Success", "Failed")
| project TimeGenerated, TimeCategory, City, AppDisplayName, Outcome
| order by TimeGenerated desc

Why this matters for investigation: After-hours sign-ins from unfamiliar cities are a primary indicator of account compromise. The AiTM attacker in Module 11 replayed stolen tokens at 3am from a different country. This query would flag that immediately — “After Hours” from a city the user has never signed in from. You are building the investigation pattern now; you will use this exact logic in Modules 11 and 13.


The let statement — naming things for clarity and reuse

What it is

let assigns a name to a value, expression, or sub-query. It creates a reference you can use later in the query. let does not change the data — it makes the query more readable, more maintainable, and more powerful.

Why it matters

Without let, complex queries become unreadable walls of text. With let, you name your building blocks and the query reads like a set of instructions: “define the time window, define the target user, then search for their sign-ins.” During an active investigation where you run the same query pattern against 5 different users, let means changing one line instead of hunting through 10 lines of logic.

More importantly, let enables sub-queries — defining a dataset in one block and referencing it in the main query. This unlocks the most important investigation patterns in KQL.

How it works — simple let (naming a value)

1
2
3
4
5
6
7
let timeWindow = 24h;
let targetUser = "j.morrison@yourdomain.onmicrosoft.com";
SigninLogs
| where TimeGenerated > ago(timeWindow)
| where UserPrincipalName == targetUser
| where ResultType != "0"
| project TimeGenerated, IPAddress, ResultType, ResultDescription

This does exactly the same thing as writing ago(24h) and the full UPN inline. But when you need to change the time window to 7 days or switch to investigating s.patel, you change one line at the top. During an investigation where you are running the same pattern for multiple users, this saves minutes per query — and during an active incident, minutes matter.

How it works — let with a sub-query (the powerful pattern)

This is where let transforms KQL from a query tool into an investigation engine.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
let suspiciousIPs =
    SigninLogs
    | where TimeGenerated > ago(7d)
    | where ResultType != "0"
    | summarize FailCount = count() by IPAddress
    | where FailCount > 10
    | project IPAddress;
SigninLogs
| where TimeGenerated > ago(7d)
| where ResultType == "0"
| where IPAddress in (suspiciousIPs)
| project TimeGenerated, UserPrincipalName, IPAddress, AppDisplayName

Read this carefully — it is one of the most important query patterns in this entire course.

The let block creates a list of IP addresses that had more than 10 failed sign-ins. It asks: “which IPs are brute-forcing accounts?” The main query then finds SUCCESSFUL sign-ins from those exact IPs. It asks: “did any of those brute-forcing IPs eventually get in?”

The combination answers the critical investigation question: “Did the brute-force attack succeed?”

Expected Output
TimeGeneratedUserPrincipalNameIPAddressAppDisplayName
08:14:22j.morrison@...198.51.100.44Exchange Online
08:15:01j.morrison@...198.51.100.44SharePoint Online
What to look for: A successful sign-in from an IP that failed 10+ times is the textbook brute-force success indicator. IP 198.51.100.44 failed repeatedly, then succeeded — j.morrison's account is likely compromised. The attacker immediately accessed Exchange Online (email) and SharePoint Online (files). This query pattern is one you will use in Module 9 (building it into a detection rule) and Module 11 (using it during the AiTM investigation).
Every let statement must end with a semicolon

The most common KQL syntax error for beginners. Every let line ends with ;. The main query after the let blocks does NOT end with a semicolon. If your query fails with "Syntax error: expected ';'", check the let statements first.


The order by operator — sorting results for investigation

What it is

order by (alias: sort by) arranges rows by one or more columns. It does not filter or transform — it reorders.

Why it matters

In security investigation, sort order determines how you read the data. desc (newest first) answers “what just happened?” — the default for alert triage. asc (oldest first) answers “what happened in sequence?” — the default for timeline reconstruction. Choosing the right sort order is the difference between seeing the most recent event first (triage mode) and reading the attack story from beginning to end (investigation mode).

How it works

1
2
3
4
5
6
7
8
// Triage mode: newest first  what just happened?
| order by TimeGenerated desc

// Investigation mode: oldest first  what happened in sequence?
| order by TimeGenerated asc

// Grouped investigation: all events per user, newest first within each user
| order by UserPrincipalName asc, TimeGenerated desc

The search operator — when you don’t know where to look

What it is

search scans across all columns in one or more tables for a value. It is the broadest possible query — the KQL equivalent of Ctrl+F across your entire workspace.

Why it matters

During an investigation, you often start with a single IOC — an IP address, a domain name, a file hash — and you do not know which table contains data about it. Is this IP in SigninLogs? DeviceNetworkEvents? CommonSecurityLog? search finds it everywhere, then you switch to targeted where queries for the actual investigation.

How it works

1
2
3
4
5
6
// Search everywhere for an IP
search "198.51.100.44"

// Search specific tables (faster)
search in (SigninLogs, AADNonInteractiveUserSignInLogs, DeviceNetworkEvents)
    "198.51.100.44"
Use search for discovery, not investigation

search is slow because it scans every column in every row. Use it once to discover which tables contain your IOC. Then switch to TableName | where ColumnName == "value" for the actual investigation. If you use search throughout an investigation, your queries will take 10-100x longer than targeted queries.


Putting it all together — your first complete investigation query

You have now learned every operator needed to build a real investigation query. This section combines them into a single query, built step by step from a realistic scenario.

Scenario: An alert fires: “Multiple failed sign-ins followed by a success from IP 198.51.100.44.” You need to determine who was targeted, whether the attack succeeded, and what the attacker did after gaining access.

Build this with your lab open. Run each step.

Step 1: Find all activity from the suspicious IP.

1
2
3
SigninLogs
| where TimeGenerated > ago(24h)
| where IPAddress == "198.51.100.44"

This gives you everything — failed and successful — from that IP. But it is too raw. Add context.

Step 2: Classify each sign-in and add time context.

1
2
3
4
5
SigninLogs
| where TimeGenerated > ago(24h)
| where IPAddress == "198.51.100.44"
| extend Outcome = iff(ResultType == "0", "SUCCESS", "FAILED")
| extend HoursAgo = round(datetime_diff('hour', now(), TimeGenerated), 1)

Every row now has a clear SUCCESS/FAILED label and a recency value.

Step 3: Shape and sort for timeline reading.

1
2
3
4
5
6
7
8
SigninLogs
| where TimeGenerated > ago(24h)
| where IPAddress == "198.51.100.44"
| extend Outcome = iff(ResultType == "0", "SUCCESS", "FAILED")
| extend HoursAgo = round(datetime_diff('hour', now(), TimeGenerated), 1)
| project TimeGenerated, HoursAgo, Outcome, UserPrincipalName,
    AppDisplayName, ResultDescription
| order by TimeGenerated asc

Sorting asc (oldest first) gives you a timeline — read the attack story from top to bottom.

Expected Output — The Attack Timeline
TimeGeneratedHoursAgoOutcomeUserAppResult
08:12:016.8FAILEDj.morrisonMicrosoft OfficeInvalid password
08:12:036.8FAILEDj.morrisonMicrosoft OfficeInvalid password
08:12:056.8FAILEDj.morrisonMicrosoft OfficeInvalid password
08:12:446.7FAILEDs.patelAzure PortalInvalid password
08:12:466.7FAILEDs.patelAzure PortalAccount locked
08:14:226.7SUCCESSj.morrisonExchange Online
08:15:016.7SUCCESSj.morrisonSharePoint Online
Read this timeline like an analyst: Three rapid failures against j.morrison (brute force in progress). Two failures against s.patel (the attacker tried another account, hit the lockout threshold). Then a SUCCESS for j.morrison to Exchange Online 2 minutes later — the attacker found the right password. Immediately followed by SharePoint access — the attacker is exploring the compromised account's data. This is a complete attack story in 7 rows: brute-force → credential discovered → mailbox access → file access. Investigation conclusion: j.morrison's account is compromised. Next steps: revoke all sessions, reset password, investigate what was accessed in Exchange and SharePoint (Module 11 teaches this in full).

Capstone exercise

Write this from scratch — no looking back

Close this page (or scroll so you cannot see the queries above). Open your Sentinel Logs editor. Write a query from memory that does the following:

1. Queries SigninLogs for the last 7 days

2. Filters to a specific user (pick one of your test users)

3. Adds a column classifying each sign-in as "Business Hours" or "After Hours"

4. Adds a column showing the country from LocationDetails

5. Adds a column showing "Success" or "Failed"

6. Shows only: TimeGenerated, the time category, the country, the application name, and the outcome

7. Sorts by time, newest first

Do not look at the course until you have a working query. If the query fails, read the error message, remove lines from the bottom until it runs, identify the broken line, fix it. The debugging is part of the learning.

1
2
3
4
5
6
7
8
9
SigninLogs
| where TimeGenerated > ago(7d)
| where UserPrincipalName has "morrison"
| extend City = tostring(LocationDetails.city)
| extend Hour = hourofday(TimeGenerated)
| extend TimeCategory = iff(Hour >= 8 and Hour < 18, "Business Hours", "After Hours")
| extend Outcome = iff(ResultType == "0", "Success", "Failed")
| project TimeGenerated, TimeCategory, City, AppDisplayName, Outcome
| order by TimeGenerated desc

Self-check: Did you use has (not contains) for the username? Did you remember tostring() for the JSON extraction? Did you place project after all extend statements? If your query works but looks different from this solution, that is fine — multiple valid approaches exist. What matters is that the output answers the question: “What did this user do, when, from where, and did it succeed?”


Common mistakes and how to fix them

Error message you seeWhat went wrongHow to fix it
'ColumnName' is not a recognized columnYou used project earlier in the query and removed the column you are now referencingMove project to the end of the query, after all filtering and extend operations
Syntax error: expected ';'Missing semicolon after a let statementAdd ; at the end of every let line. The main query after the let blocks does NOT have a semicolon.
Query returns 0 resultsTime range too narrow, or a filter is excluding everythingWiden ago() to 7d. Remove where clauses one at a time from the bottom to identify which filter is too aggressive.
Cannot convert from 'dynamic' to 'string'Filtering or projecting a JSON/dynamic field without type conversionWrap the field in tostring(): tostring(LocationDetails.city)
Query runs but takes 60+ secondsUsing search across all tables, or contains on a large tableReplace search with targeted where on specific tables. Replace contains with has.
The expression refers to column 'X' which doesn't existTypo in column name, or the column exists in a different tableCheck the table schema: `TableName
Debugging method: remove lines from the bottom until it runs

When a query fails, do not stare at 10 lines trying to spot the mistake. Remove the last line. Run it. Still fails? Remove the next-to-last line. Run it. The moment the query succeeds, the line you just removed contains the error. Read the error message for that specific line and fix it. This is faster than reading and is the same approach experienced analysts use in production.


Check your understanding

1. You are investigating a suspicious IP address (203.0.113.45) but you do not know which Sentinel table contains data about it. Which operator do you use first, and why would you switch to a different approach once you find the data?

Start with search "203.0.113.45" to discover which tables contain the IP. Once you identify the relevant tables (e.g., SigninLogs, DeviceNetworkEvents), switch to targeted where queries: SigninLogs | where IPAddress == "203.0.113.45". search is a discovery tool (slow, broad). where is an investigation tool (fast, precise).
Use where against every table one at a time
Use search for the entire investigation
IP addresses are only stored in DeviceNetworkEvents

2. Your query uses | where UserPrincipalName contains "morrison" and takes 45 seconds on a table with 10 million rows. How do you make it faster without changing the results?

Replace contains with has. The has operator uses the table's term index, completing in milliseconds instead of seconds. Both return the same results for whole-word searches. contains scans every character in every row without an index.
Add more where clauses
Use search instead
Reduce the time range

3. You write | project TimeGenerated, UserPrincipalName on line 3, then | where IPAddress == "198.51.100.44" on line 4. The query fails. Why?

project on line 3 removed all columns except TimeGenerated and UserPrincipalName. Line 4 references IPAddress, which no longer exists in the pipeline. Fix: move project to the last line, after all filtering and computation. Rule: filter first, compute second, project last.
IPAddress is not a valid SigninLogs column
project and where cannot appear in the same query
Missing pipe character

4. An alert says "Brute force attack detected against j.morrison." You need to determine whether the attack succeeded. What is the correct investigation approach?

Query for failed sign-ins only: | where ResultType != "0"
Query for successful sign-ins only: | where ResultType == "0"
Search all tables for "morrison"
Use a let sub-query to find IPs with 10+ failures, then query for successful sign-ins from those IPs. The alert already tells you there were failures. The investigation question is: did failures lead to a success from the same source? The let sub-query pattern correlates failure and success to answer this.

5. You are writing a query during an active incident. It is 12 lines long and fails with a syntax error. What is the fastest debugging approach?

Remove lines from the bottom one at a time until the query runs. The last line you removed contains the error. This isolates the problem in seconds — faster than reading every line for mistakes. Once identified, read the error message for that specific line and fix it.
Read the entire query line by line looking for typos
Delete everything and start over
Search the error message online