This chapter covers KQL time functions and temporal analysis, a critical skill for the SC-200 exam, especially in the Sentinel domain (Objective 2.2). Approximately 15-20% of exam questions in the 'Create and manage analytics rules' section require you to write or interpret KQL queries that manipulate time. You will learn how to use datetime literals, timespan arithmetic, time extraction functions, and binning to build time-based aggregations and detect security patterns like brute force attacks or beaconing. Mastery of these functions is essential for creating efficient and accurate detection rules in Microsoft Sentinel.
Jump to a section
KQL time functions are like a time machine for security logs. Imagine you are a detective investigating a crime that happened over several days. You have a stack of time-stamped reports, but they are in random order. To find patterns, you need to group events by hour, look for spikes at specific times, and compare activity from one day to the next. KQL's time functions let you 'travel' through your log data: you can shift all timestamps by a fixed offset (like moving a clock forward or backward), round them to the nearest hour or day (like sorting reports into hourly bins), and extract parts like the day of week or hour of day (like labeling each report with the day it occurred). The timespan data type is the fuel for your time machine—it represents a duration, like '2 hours' or '3.5 days', and can be added to or subtracted from datetime values. Just as a detective uses a timeline to connect events, KQL time functions let you create time-based aggregations, detect anomalies over sliding windows, and correlate events across different time zones. Without these functions, analyzing temporal patterns in security data would be like trying to solve a crime without a clock—you'd miss the crucial timing of events.
1. The datetime and timespan Data Types
KQL has two primary data types for temporal operations: datetime and timespan.
datetime: Represents an absolute point in time. Default format is ISO 8601 (e.g., 2025-03-15T14:30:00Z). You can write datetime literals as strings, but best practice is to use the datetime() function: datetime(2025-03-15 14:30:00). In queries, the now() function returns the current UTC time.
timespan: Represents a duration, such as 1h, 30m, 2.5d. Internally, KQL stores both as 64-bit integers (datetime as ticks since epoch, timespan as ticks). You can add or subtract a timespan from a datetime: datetime(2025-03-15) + 1d yields 2025-03-16T00:00:00Z. You can also subtract two datetimes to get a timespan: now() - datetime(2025-01-01) returns the duration since New Year.
2. Time Literals and Functions
KQL provides several functions to work with time:
now(): Returns the current UTC datetime. Use now(-1d) for 1 day ago.
ago(): Shorthand for relative time: ago(1h) is equivalent to now(-1h).
datetime(): Parses a string into datetime. Always use this instead of relying on implicit conversion.
todatetime(): Converts a value to datetime (e.g., from a dynamic field).
format_datetime(): Formats a datetime as a string (e.g., format_datetime(now(), 'yyyy-MM-dd')).
3. Extracting Parts of a Date
Use these functions to extract components:
getyear(), getmonth(), dayofmonth(), dayofweek(), dayofyear(), hourofday(), minuteofday(), secondofday().
Example: dayofweek(now()) returns an integer (0=Sunday, 6=Saturday). Note: dayofweek() returns a timespan representing the offset from Sunday, but you can compare it to 0d for Sunday, 1d for Monday, etc. However, the exam often uses datetime_part() which is more flexible.
datetime_part(part, datetime): Extracts a specific part as integer. Part can be 'Year', 'Month', 'Day', 'Hour', 'Minute', 'Second', 'Millisecond', 'Microsecond', 'Nanosecond'.
Example: datetime_part('DayOfYear', now()) returns the day number within the year.
4. Time Rounding and Binning
Binning is crucial for aggregating events into time windows. The bin() function rounds a datetime down to the nearest multiple of a given timespan.
Syntax: bin(datetime, timespan)
Example: bin(now(), 1h) rounds the current time down to the start of the current hour. If now is 14:37:22, it returns 14:00:00.
Common bin sizes: 1h, 30m, 15m, 1d. For weekly bins, use 7d but note that the base is the Unix epoch (1970-01-01). To align bins to a specific day (e.g., Monday), use startofweek() before binning.
startofday(), startofweek(), startofmonth(), startofyear(): Return the start of the respective period. startofweek() uses Sunday as the first day of the week by default.
endofday(), endofweek(), etc.: Return the end of the period.
These functions are useful for grouping events into natural time boundaries.
5. Time Zone Handling
KQL by default works in UTC. All datetime values in logs should be in UTC. If you need to convert to local time, use datetime_utc_to_local() or datetime_local_to_utc(). However, the exam expects you to work in UTC. Avoid time zone conversions unless explicitly required.
6. Common Temporal Patterns in Security Queries
Pattern 1: Time-based aggregation Count events per hour:
SecurityEvent
| where TimeGenerated > ago(24h)
| summarize Count = count() by bin(TimeGenerated, 1h)
| render timechartPattern 2: Detecting spikes with time-series functions
The series_decompose() function can detect anomalies in a time series. First, you need to create a time series using make-series:
let timeSeries =
SecurityEvent
| where TimeGenerated > ago(7d)
| make-series Count=count() on TimeGenerated from ago(7d) to now() step 1h;
timeSeries
| extend (anomalies, score, baseline) = series_decompose_anomalies(Count, 1.5, -1, 'linefit')
| render anomalychart with (anomalycolumns=anomalies)Pattern 3: Sliding window analysis Detect multiple failed logins within a 5-minute window:
SigninLogs
| where ResultType == "50057" // User account is disabled
| summarize FailedAttempts = count() by UserPrincipalName, bin(TimeGenerated, 5m)
| where FailedAttempts > 10Pattern 4: Time difference between events Calculate time between consecutive events for the same user:
SigninLogs
| where ResultType == "0" // Success
| sort by UserPrincipalName, TimeGenerated asc
| extend TimeDiff = TimeGenerated - prev(TimeGenerated, 1)
| where TimeDiff between (0s .. 5m) // Rapid successive logins7. Performance Considerations
Use where filters on TimeGenerated as early as possible. The TimeGenerated column is often indexed, so filtering on it reduces data scanned.
Avoid using datetime() on large string columns inside where clauses; instead, compare directly to datetime columns.
Binning with bin() is efficient because KQL can leverage hash-based aggregation.
8. Exam-Specific Details
The ago() function is preferred over now(-1h) because it is cleaner and more readable.
datetime_part() is the go-to function for extracting date parts. Memorize the string arguments: 'Year', 'Month', 'Day', 'Hour', 'Minute', 'Second', 'Millisecond', 'Microsecond', 'Nanosecond', 'DayOfYear', 'DayOfWeek'.
dayofweek() returns a timespan, not an integer. To get an integer (0=Sunday), use datetime_part('DayOfWeek', datetime) - 1 or toint(dayofweek(datetime)) (which returns 0-6).
startofweek() uses Sunday as the start. If you need Monday, use startofweek(datetime) + 1d.
The bin() function rounds down. For rounding to nearest, you can add half the bin size before binning.
Timespan literals: 1d, 2h, 30m, 10s, 100ms. You can also use 1.5d for 1.5 days.
now() returns UTC. In Sentinel, all logs are in UTC. Do not convert to local time unless explicitly required.
The make-series operator creates a time series with a fixed step, which is essential for anomaly detection functions like series_decompose_anomalies().
9. Common Mistakes
Using datetime as a string without conversion: where TimeGenerated > '2025-01-01' works implicitly but is less reliable. Always use datetime('2025-01-01').
Confusing dayofweek() (returns timespan) with datetime_part('DayOfWeek', ...) (returns integer).
Forgetting that bin() rounds down, not up. For example, bin(datetime(2025-03-15T14:59:59), 1h) returns 14:00:00, not 15:00:00.
Using now() in a let statement that is evaluated once; if you need the current time for each row, use now() in the query body.
10. Advanced: Calendar and Business Hours
For business hours analysis, you can create a custom function:
let IsBusinessHour = (t:datetime) {
let h = datetime_part('Hour', t);
let dow = datetime_part('DayOfWeek', t); // 1=Sunday, 7=Saturday
print case(
dow between (2 .. 6) and h between (9 .. 17), true,
false
)
};But this is beyond the exam scope.
Identify the time column
Every security log in Microsoft Sentinel has a `TimeGenerated` column (datetime, UTC). This is the primary time field used for temporal analysis. For custom logs, there may be other datetime fields. Always filter on `TimeGenerated` first to limit the data scope. Use `where TimeGenerated > ago(24h)` to get the last 24 hours. This step is critical for performance because Sentinel indexes `TimeGenerated`. Without this filter, the query may scan all data in the workspace, causing timeouts and throttling.
Filter to relevant time range
Use `ago()` or `datetime()` to define the time window. For example, `where TimeGenerated between (ago(7d) .. ago(1d))` gives data from 7 days ago to 1 day ago. Avoid using `now()` in a `let` statement if you need the same 'now' throughout; instead, use `let start = now(-7d); let end = now();` and then `where TimeGenerated between (start .. end)`. This ensures consistency across the query. The exam often tests the difference between `ago(1d)` (24 hours ago) and `now(-1d)` (same as ago).
Round timestamps with bin()
Use `bin(TimeGenerated, 1h)` to group events into hourly buckets. This is essential for `summarize` operations. For example, `summarize count() by bin(TimeGenerated, 1h)` creates a time series. The bin size should match the desired granularity: `1h` for hourly, `1d` for daily. Remember that `bin()` rounds down, so events at 14:59:59 fall into the 14:00:00 bin. If you need to align to natural boundaries (e.g., start of day), use `startofday()` instead of `bin()`.
Extract date parts for analysis
Use `datetime_part()` to extract components like hour, day of week, or month. For example, `extend Hour = datetime_part('Hour', TimeGenerated)` then `summarize count() by Hour`. This helps detect patterns like brute force attacks that spike during off-hours. The exam expects you to know the string arguments: 'DayOfWeek' returns 1=Sunday, 7=Saturday. To get Monday=1, you can subtract 1 or use `case` logic. `datetime_part('DayOfYear', ...)` is useful for annual comparisons.
Calculate time differences
Use subtraction of datetime values to get a timespan. For example, `extend TimeSinceLastEvent = TimeGenerated - prev(TimeGenerated, 1)` in a sorted dataset. This is used for detecting rapid successive events (e.g., password spray). The `prev()` function requires a sort order. You can also use `row_cumsum()` for cumulative sums over time. The exam may ask you to find events where the time difference is less than a threshold, like `where TimeSinceLastEvent < 1m`.
Scenario 1: Brute Force Detection in Enterprise Environment
A large enterprise with 50,000 users uses Azure AD Sign-in logs. The security team needs to detect brute force attacks where an attacker tries multiple passwords for the same user within a short window. They create a KQL query that bins TimeGenerated by 5-minute windows and counts failed logins per user. The query:
SigninLogs
| where ResultType == "50057" // User account is disabled
| summarize FailedAttempts = count() by UserPrincipalName, bin(TimeGenerated, 5m)
| where FailedAttempts > 10In production, they must consider that legitimate users might trigger false positives if they have multiple devices. To reduce noise, they add a condition on AppDisplayName to exclude known internal apps. Performance is critical: the query scans billions of rows daily, so they always filter on TimeGenerated > ago(1d) first. Misconfiguration: if they use bin() on a non-indexed column, the query becomes slow. They also discovered that ago(1d) returns exactly 24 hours ago, which may miss events if the query runs at a different time; they now use a fixed datetime range for historical analysis.
Scenario 2: Anomaly Detection on Network Traffic
A managed security service provider (MSSP) monitors network traffic for hundreds of clients. They use make-series to create hourly traffic counts and apply series_decompose_anomalies() to detect spikes. The query:
CommonSecurityLog
| where TimeGenerated > ago(7d)
| make-series TrafficCount=count() on TimeGenerated from ago(7d) to now() step 1h by DeviceVendor
| extend (anomalies, score, baseline) = series_decompose_anomalies(TrafficCount, 1.5, -1, 'linefit')
| mv-expand TimeGenerated, TrafficCount, anomalies, score, baseline
| where anomalies == 1In production, they tune the threshold parameter (1.5) to balance sensitivity. They also use series_decompose_forecast() for predictive alerts. Misconfiguration: if the step is too small (e.g., 1m), the time series becomes noisy and anomaly detection fails. They set step based on the typical attack duration. They also ensure that now() is evaluated per query run, not cached.
Scenario 3: Time Zone Confusion in Global Deployments
A multinational company has offices worldwide. Their logs are in UTC, but analysts want to see events in local time. They use datetime_utc_to_local() with the correct time zone, e.g., datetime_utc_to_local(TimeGenerated, 'US Eastern Standard Time'). However, this function is not available in all KQL contexts; in Sentinel, it works. They must be careful with daylight saving time transitions. Misconfiguration: using datetime_local_to_utc() on already UTC data shifts times incorrectly. The security team learned to always store logs in UTC and only convert for display.
What the SC-200 Exam Tests
Objective 2.2: 'Create and manage analytics rules in Microsoft Sentinel'. This includes writing KQL queries that use time functions for detection. The exam expects you to:
Use ago(), now(), datetime() correctly.
Apply bin() for time-based aggregation.
Extract date parts with datetime_part().
Calculate time differences using subtraction and prev().
Understand the difference between dayofweek() (returns timespan) and datetime_part('DayOfWeek', ...) (returns integer).
Know that startofweek() uses Sunday as the first day.
Recognize that TimeGenerated is the primary time column in Sentinel.
Common Wrong Answers and Why
Using `now()` in a `let` statement and expecting it to be re-evaluated per row: let currentTime = now(); is evaluated once. If you need per-row evaluation, use now() directly in the query. Wrong answer: 'Use let to store the current time for performance.'
Confusing `ago(1d)` with `now(-24h)`: They are functionally identical, but ago() is cleaner. The exam may test that ago(1d) is equivalent to now(-1d). Wrong answer: 'ago(1d) returns the start of the day.'
Using `bin()` on a string column: bin() only works on numeric and datetime columns. Wrong answer: 'Use bin() to group text values.'
Assuming `dayofweek()` returns an integer: It returns a timespan. To get an integer, use datetime_part('DayOfWeek', ...). Wrong answer: 'dayofweek(now()) returns 1 for Monday.'
Forgetting that `bin()` rounds down: For example, bin(datetime(2025-03-15T14:59:59), 1h) returns 14:00:00, not 15:00:00. Wrong answer: 'It rounds to the nearest hour.'
Specific Numbers and Values
ago(7d) is the most common time range for hunting queries.
bin(TimeGenerated, 1h) is the standard for hourly aggregation.
datetime_part('DayOfWeek', ...) returns 1 (Sunday) through 7 (Saturday).
startofweek() returns the preceding Sunday at midnight.
timespan literals: 1d, 2h, 30m, 10s, 100ms.
now() returns UTC; Sentinel logs are in UTC.
Edge Cases and Exceptions
When using make-series, the from and to arguments must be datetime literals or ago(); using now() inside make-series is allowed but evaluated once.
series_decompose_anomalies() requires a time series with uniform step; gaps are filled with fill_forward().
Time zone functions datetime_utc_to_local() and datetime_local_to_utc() are available in Sentinel but not in Azure Data Explorer directly.
How to Eliminate Wrong Answers
If a query uses datetime as a string without datetime(), it is likely incorrect because implicit conversion can fail.
If a query uses bin() on a non-datetime column, it will cause an error.
If a query uses dayofweek() in a where clause comparing to an integer, it will not work because dayofweek() returns a timespan. Look for datetime_part('DayOfWeek', ...) instead.
If a query uses now() inside a let and then uses that variable in a where clause that is expected to be dynamic per row, it is wrong.
If a query uses startofweek() and expects Monday, it is wrong; Sunday is the default.
Always filter on TimeGenerated as early as possible to reduce data scan and improve performance.
Use ago() for relative time ranges; it is cleaner than now(-1d) and is preferred in Sentinel queries.
bin() rounds down to the nearest multiple of the specified timespan; it does not round to the nearest.
datetime_part('DayOfWeek', datetime) returns 1 (Sunday) through 7 (Saturday); dayofweek() returns a timespan.
startofweek() returns the preceding Sunday; to get Monday, add 1d.
now() returns UTC; all Sentinel logs are in UTC; avoid time zone conversions unless explicitly required.
Use make-series for creating time series with uniform step for anomaly detection functions.
timespan literals: 1d, 2h, 30m, 10s, 100ms; can be fractional like 1.5d.
The prev() function requires a sort order; use sort by before extend to calculate time differences between consecutive events.
In let statements, now() is evaluated once; use now() directly in the query body for per-row evaluation.
These come up on the exam all the time. Here's how to tell them apart.
bin()
Rounds down to the nearest multiple of a specified timespan (e.g., 1h, 1d).
Works with any timespan value, not just natural boundaries.
Useful for arbitrary time windows like 5-minute bins.
Example: bin(TimeGenerated, 1h) groups events into hourly buckets starting from epoch.
The base alignment is relative to Unix epoch (1970-01-01), so bins may not align with natural days if using non-24h bins.
startofday()/startofweek()
Returns the start of a natural time period (day, week, month, year).
Only works for predefined periods: startofday, startofweek, startofmonth, startofyear.
Useful for aligning to calendar boundaries (e.g., start of today).
Example: startofday(TimeGenerated) groups events into calendar days.
startofweek uses Sunday as the first day of the week; can be adjusted by adding 1d for Monday.
Mistake
ago(1d) returns the start of the current day.
Correct
ago(1d) returns a datetime exactly 24 hours before the current time. For example, if now is 2025-03-15T14:30:00Z, ago(1d) returns 2025-03-14T14:30:00Z. To get the start of the current day, use startofday(now()).
Mistake
dayofweek() returns an integer representing the day of the week.
Correct
dayofweek() returns a timespan representing the offset from Sunday. For example, dayofweek(datetime('2025-03-17')) (Monday) returns 1d (a timespan of 1 day). To get an integer, use datetime_part('DayOfWeek', datetime) which returns 1 for Sunday, 2 for Monday, etc., or use toint(dayofweek(datetime)) which returns 0 for Sunday, 1 for Monday, etc.
Mistake
bin() rounds a datetime to the nearest time unit.
Correct
bin() always rounds down to the nearest multiple of the specified timespan. For example, bin(datetime('2025-03-15T14:59:59'), 1h) returns 2025-03-15T14:00:00, not 15:00:00. To round to the nearest, you can add half the bin size before binning: bin(datetime + 0.5h, 1h).
Mistake
startofweek() returns Monday as the first day of the week.
Correct
startofweek() returns the preceding Sunday at midnight. For example, startofweek(datetime('2025-03-19')) (Wednesday) returns 2025-03-16T00:00:00 (Sunday). To get Monday, use startofweek(datetime) + 1d.
Mistake
now() in a let statement is re-evaluated for each row in the query.
Correct
let statements are evaluated once when the query is parsed. If you use let currentTime = now();, it captures the time at the start of query execution. For per-row evaluation, use now() directly in the query body, e.g., where TimeGenerated > now(-1d).
Reveal each answer, then mark whether you got it right. Score 60%+ to unlock the next chapter.
They are functionally identical: ago(1d) returns the same as now(-1d). However, ago() is cleaner and more readable, and it is the preferred syntax in Microsoft Sentinel. The exam expects you to use ago() for relative time filters. For example, `where TimeGenerated > ago(7d)` is standard.
Use datetime_part('DayOfWeek', datetime) which returns 1 for Sunday, 2 for Monday, ..., 7 for Saturday. Alternatively, toint(dayofweek(datetime)) returns 0 for Sunday, 1 for Monday, ..., 6 for Saturday. The exam often tests that dayofweek() returns a timespan, not an integer.
bin() rounds down to the nearest multiple of the specified timespan relative to the Unix epoch (1970-01-01). For example, bin(TimeGenerated, 1d) may not align with midnight if the epoch offset is not a multiple of 1 day. To align to calendar days, use startofday() instead.
No. let statements are evaluated once at query start. If you need the current time to be evaluated for each row, use now() directly in the query body. For example, `where TimeGenerated > now(-1h)` is evaluated per row.
First, sort the data by the entity and time: `sort by UserPrincipalName, TimeGenerated asc`. Then use `extend TimeDiff = TimeGenerated - prev(TimeGenerated, 1)`. The prev() function returns the previous row's value. Then you can filter on TimeDiff, e.g., `where TimeDiff < 1m`.
Sunday. startofweek(datetime) returns the preceding Sunday at midnight. If you need Monday, use startofweek(datetime) + 1d.
Use the make-series operator: `make-series Count=count() on TimeGenerated from ago(7d) to now() step 1h by SomeColumn`. This creates a time series with uniform step. Then apply series_decompose_anomalies() to detect spikes.
You've just covered KQL Time Functions and Temporal Analysis — now see how well it sticks with free SC-200 practice questions. Full explanations included, no account needed.
Done with this chapter?