Azure Data Explorer and KQL Interview Questions and Answers Part 2 - KQL Query Essentials

Azure Data Explorer Interview Q&A · Part 2 of 10

In this post we are going to cover KQL query essentials. These are 7 real Azure Data Explorer and KQL interview questions for data engineers, asked the way they come up on a real project, and the last one is a scenario: support asks for the top ten customers with the most failed requests today and their error rate.

These are the operators we write every day: where, project, extend, summarize, has, arg_max, let and stored functions. Interviewers ask about them because small details decide whether a query is right and fast: which filter goes first, which string operator uses the index, and where integer division quietly changes a number.

Part 2 of the Real Time Azure Data Explorer and KQL Interview Questions and Answers series on TechBrothersIT.

The short answer

Say this first

Filter time first, then values. project shapes, extend adds, project-away drops. summarize ... by bin() groups by time, has uses the term index while contains scans, arg_max returns the latest whole row, and let and stored functions name logic once.

Let's create some sample data

Every query in this post runs on the Kusto emulator, Microsoft's Azure Data Explorer engine in a local container, in a database called shop. setup.kql creates two small invented tables: Requests, 18 web requests loaded from three CSV files with .ingest into, and Telemetry, 8 device readings loaded with .ingest inline. Both are for exploration and prototyping, not production. kql is a small helper that sends a query to the emulator and prints the result as a text table. Queries that need a live table, such as ago(1h), are shown as code.

requests_part1.csv
2026-10-20T08:05:00Z,Info,checkout,C001,120,order placed
2026-10-20T08:12:00Z,Error,payments,C002,3050,db-timeout while charging card
2026-10-20T08:20:00Z,Info,catalog,C003,45,search ok
2026-10-20T08:41:00Z,Warning,checkout,C001,900,slow response from payments.api
2026-10-20T08:55:00Z,Error,checkout,C004,1500,Error: payment declined
2026-10-20T09:02:00Z,Info,catalog,C002,50,search ok
requests_part2.csv
2026-10-20T09:10:00Z,Error,payments,C002,3010,db-timeout while charging card
2026-10-20T09:15:00Z,Info,checkout,C005,130,order placed
2026-10-20T09:31:00Z,Error,payments,C001,2900,ERROR connecting to db.primary
2026-10-20T09:47:00Z,Info,catalog,C003,40,search ok
2026-10-20T11:03:00Z,Info,checkout,C003,110,order placed
2026-10-20T11:09:00Z,Error,checkout,C002,1700,user error: invalid coupon
requests_part3.csv
2026-10-20T11:20:00Z,Warning,catalog,C005,700,cache miss
2026-10-20T11:44:00Z,Error,payments,C004,3100,err code 42 from gateway
2026-10-20T11:58:00Z,Info,payments,C005,200,refund issued
2026-10-20T12:06:00Z,Error,payments,C002,2950,db-timeout while charging card
2026-10-20T12:30:00Z,Info,checkout,C001,125,order placed
2026-10-20T12:52:00Z,Info,catalog,C004,48,search ok
setup.kql
.create table Requests (Timestamp:datetime, Level:string, Service:string,
    CustomerId:string, DurationMs:long, Message:string)

.ingest into table Requests (@"/kustodata/requests_part1.csv") with (format="csv")

.ingest into table Requests (@"/kustodata/requests_part2.csv") with (format="csv")

.ingest into table Requests (@"/kustodata/requests_part3.csv") with (format="csv")

.create table Telemetry (Timestamp:datetime, DeviceId:string, Temp:real,
    Battery:long, Status:string)

.ingest inline into table Telemetry <|
2026-10-20T08:00:00Z,dev-01,21.4,98,ok
2026-10-20T08:00:00Z,dev-02,19.8,76,ok
2026-10-20T08:00:00Z,dev-03,23.1,54,ok
2026-10-20T08:15:00Z,dev-01,21.9,97,ok
2026-10-20T08:15:00Z,dev-02,20.4,75,ok
2026-10-20T08:30:00Z,dev-01,22.6,97,ok
2026-10-20T08:30:00Z,dev-03,27.9,41,warn
2026-10-20T08:45:00Z,dev-02,20.1,74,ok

Question 1: A teammate needs the last hour of errors from a logs table. How do you filter by time and value in KQL?

Two where clauses, time first. Put the time filter right after the table, because the engine indexes datetime columns and can skip whole shards outside the window. Then filter the value.

Our sample data is fixed in October, so a let pins now to 12:10 on October 20. We run q1.kql:

q1.kql
let now_ = datetime(2026-10-20 12:10);   // pinned "now"
Requests
| where Timestamp between (now_ - 1h .. now_)
| where Level == "Error"
| project Timestamp, Service, Message
| order by Timestamp asc
Terminal
$ kql -f q1.kql
> let now_ = datetime(2026-10-20 12:10);   // pinned "now"
Timestamp             Service   Message
--------------------  --------  ------------------------------
2026-10-20T11:44:00Z  payments  err code 42 from gateway
2026-10-20T12:06:00Z  payments  db-timeout while charging card
Result table2 rows
TimestampServiceMessage
2026-10-20T11:44:00Zpaymentserr code 42 from gateway
2026-10-20T12:06:00Zpaymentsdb-timeout while charging card

The last hour holds two payments errors: err code 42 from gateway at 11:44, and db-timeout while charging card at 12:06. The coupon error at 11:09 is just outside the window.

On a live table nothing is newer than now, so where Timestamp > ago(1h) does it:

q1_live.kql (a live table)
// on a live table
Requests
| where Timestamp > ago(1h)
| where Level == "Error"
| project Timestamp, Service, Message
| order by Timestamp desc

Two details matter here:

  • == is case-sensitive, so the level must match exactly.
  • Filter on table columns, not calculated ones: compare Timestamp itself, don't wrap it in a function first.
Real project

On a table I don't know yet, I start with take 10 or a count before running anything big.

Question 2: Someone asks the difference between project, extend and project-away. When do you use each?

project keeps only the columns you list, and can rename or compute them. extend keeps every column and adds new ones. project-away drops the columns you name and keeps the rest. project-keep and project-rename complete the family:

OperatorWhat it doesColumns after it
projectkeeps only the listed columns; can rename and computejust the list
extendadds computed columnsall of them, plus the new ones
project-awaydrops the listed columnsall the others
project-keepkeeps the listed columnsin the table's own order
project-renamerenames columnssame columns, new names

Let's try two of them on the error rows. extend adds Seconds and a Slow flag, and project-away drops Message and CustomerId. We run q2.kql:

q2.kql
Requests
| where Level == "Error"
| extend Seconds = DurationMs / 1000.0,
    Slow = DurationMs > 2000
| project-away Message, CustomerId
| order by Timestamp asc
Terminal
$ kql -f q2.kql
> Requests
Timestamp             Level  Service   DurationMs  Seconds  Slow
--------------------  -----  --------  ----------  -------  -----
2026-10-20T08:12:00Z  Error  payments  3050        3.05     true
2026-10-20T08:55:00Z  Error  checkout  1500        1.5      false
2026-10-20T09:10:00Z  Error  payments  3010        3.01     true
2026-10-20T09:31:00Z  Error  payments  2900        2.9      true
2026-10-20T11:09:00Z  Error  checkout  1700        1.7      false
2026-10-20T11:44:00Z  Error  payments  3100        3.1      true
2026-10-20T12:06:00Z  Error  payments  2950        2.95     true
Result table7 rows
TimestampLevelServiceDurationMsSecondsSlow
2026-10-20T08:12:00ZErrorpayments30503.05true
2026-10-20T08:55:00ZErrorcheckout15001.5false
2026-10-20T09:10:00ZErrorpayments30103.01true
2026-10-20T09:31:00ZErrorpayments29002.9true
2026-10-20T11:09:00ZErrorcheckout17001.7false
2026-10-20T11:44:00ZErrorpayments31003.1true
2026-10-20T12:06:00ZErrorpayments29502.95true

Everything else stays in the table's order, with the new columns at the end: the first error is 3050 ms, 3.05 seconds, Slow is true.

When do I use each? extend for derived columns, project at the end for the shape a report needs, and project-away when a table is wide and I only want to drop a few columns.

Now the math. A long divided by a long is integer division. We run q2b.kql on the checkout rows:

q2b.kql
Requests
| where Service == "checkout"
| project Timestamp, DurationMs,
    Sec = DurationMs / 1000, Seconds = DurationMs / 1000.0
| order by Timestamp asc
Terminal
$ kql -f q2b.kql
> Requests
Timestamp             DurationMs  Sec  Seconds
--------------------  ----------  ---  -------
2026-10-20T08:05:00Z  120         0    0.12
2026-10-20T08:41:00Z  900         0    0.9
2026-10-20T08:55:00Z  1500        1    1.5
2026-10-20T09:15:00Z  130         0    0.13
2026-10-20T11:03:00Z  110         0    0.11
2026-10-20T11:09:00Z  1700        1    1.7
2026-10-20T12:30:00Z  125         0    0.125

120 ms divided by 1000 gives 0. Divided by 1000.0, we get 0.12.

Tip

On wide tables, project before a join or an arg_max with *: fewer columns means less data to carry and return.

Question 3: A report needs counts per hour per service. How does summarize with bin work?

summarize groups rows by the keys after by, and runs the aggregates once per group. bin(Timestamp, 1h) rounds each timestamp down to the hour, so times turn into hourly buckets we can group by.

Let's count all requests, and the errors with countif, by hour and service, ordered by both keys. We run q3.kql:

q3.kql
Requests
| summarize Requests = count(),
    Errors = countif(Level == "Error")
    by bin(Timestamp, 1h), Service
| order by Timestamp asc, Service asc
Terminal
$ kql -f q3.kql
> Requests
Timestamp             Service   Requests  Errors
--------------------  --------  --------  ------
2026-10-20T08:00:00Z  catalog   1         0
2026-10-20T08:00:00Z  checkout  3         1
2026-10-20T08:00:00Z  payments  1         1
2026-10-20T09:00:00Z  catalog   2         0
2026-10-20T09:00:00Z  checkout  1         0
2026-10-20T09:00:00Z  payments  2         2
2026-10-20T11:00:00Z  catalog   1         0
2026-10-20T11:00:00Z  checkout  2         1
2026-10-20T11:00:00Z  payments  2         1
2026-10-20T12:00:00Z  catalog   1         0
2026-10-20T12:00:00Z  checkout  1         0
2026-10-20T12:00:00Z  payments  1         1

At 08:00, checkout had 3 requests and 1 error, and payments had 1 request, which failed.

Now look at the hours: 08:00, 09:00, 11:00 and 12:00. There's no 10:00 group, because no request arrived then, and summarize only returns groups that exist. For a chart without gaps, make-series fills them; that is in the next part of this series.

Two habits:

  • Name the aggregates: or you get count_
  • Bin explicitly: the automatic hourly bins for datetime columns are no longer supported
Pitfall

One-minute bins over a month: over 43,000 buckets per service. Match the bin to the chart.

Question 4: A teammate asks the difference between has, contains and ==. Which one do you use and why?

has matches a whole term and uses the term index. contains matches any substring and scans. == is exact and case-sensitive; =~ ignores case.

Kusto indexes string columns too. It splits text into terms, runs of letters and digits, and indexes terms of three characters or more. That is the index has can use.

Let's take every message that contains err and test has on it. We run q4.kql:

q4.kql
Requests
| where Message contains "err"
| project Message,
    HasErr = Message has "err",
    HasError = Message has "error"
| order by Message asc
Terminal
$ kql -f q4.kql
> Requests
Message                         HasErr  HasError
------------------------------  ------  --------
ERROR connecting to db.primary  false   true
Error: payment declined         false   true
err code 42 from gateway        true    false
user error: invalid coupon      false   true
Result table4 rows
MessageHasErrHasError
ERROR connecting to db.primaryfalsetrue
Error: payment declinedfalsetrue
err code 42 from gatewaytruefalse
user error: invalid couponfalsetrue

Four messages contain the letters err, but has "err" is true for only one, err code 42 from gateway. In the others, err is part of the longer term error, which has "error" matches, in any case.

Now the counts over all 18 rows. We run q4b.kql:

q4b.kql
Requests
| summarize HasError = countif(Message has "error"),
    HasErr = countif(Message has "err"),
    ContainsErr = countif(Message contains "err"),
    LevelEq = countif(Level == "error"),
    LevelEqi = countif(Level =~ "error")
Terminal
$ kql -f q4b.kql
> Requests
HasError  HasErr  ContainsErr  LevelEq  LevelEqi
--------  ------  -----------  -------  --------
3         1       4            0        7
Result table1 row
HasErrorHasErrContainsErrLevelEqLevelEqi
31407

has "error" gives 3, has "err" 1, contains "err" 4. Level == "error" in lower case gives 0, because it's case-sensitive, and =~ finds 7.

So I use has for words in log text, == for exact values, and contains only when I truly need a substring. Microsoft says has works faster than contains.

Trap

A term shorter than three characters isn't indexed, so that search scans. And has won't find part of a word, by design.

Question 5: Someone needs the latest row per device. How do you use arg_max?

arg_max finds, per group, the row where an expression is largest, and returns that row's other columns too. summarize arg_max(Timestamp, *) by DeviceId gives the whole latest row per device, in one pass.

First the naive way, max(Timestamp) by device. We run q5a.kql:

q5a.kql
Telemetry
| summarize Last = max(Timestamp) by DeviceId
| order by DeviceId asc
Terminal
$ kql -f q5a.kql
> Telemetry
DeviceId  Last
--------  --------------------
dev-01    2026-10-20T08:30:00Z
dev-02    2026-10-20T08:45:00Z
dev-03    2026-10-20T08:30:00Z

It gives the latest time, but the temperature, battery and status are gone, and we'd need a join back to get them. Now with arg_max. We run q5.kql:

q5.kql
Telemetry
| summarize arg_max(Timestamp, *) by DeviceId
| order by DeviceId asc
Terminal
$ kql -f q5.kql
> Telemetry
DeviceId  Timestamp             Temp  Battery  Status
--------  --------------------  ----  -------  ------
dev-01    2026-10-20T08:30:00Z  22.6  97       ok
dev-02    2026-10-20T08:45:00Z  20.1  74       ok
dev-03    2026-10-20T08:30:00Z  27.9  41       warn
Result table3 rows
DeviceIdTimestampTempBatteryStatus
dev-012026-10-20T08:30:00Z22.697ok
dev-022026-10-20T08:45:00Z20.174ok
dev-032026-10-20T08:30:00Z27.941warn

dev-01 at 08:30 with 22.6 degrees, dev-02 at 08:45, and dev-03 at 08:30 with status warn. The key comes first, then the timestamp, then the rest.

Instead of *, you can list columns, like Temp and Status, to keep the result narrow. And if two rows share the latest time, don't rely on which one you get: dedupe first.

Real project

A latest-status dashboard runs this all day, so I put it in a materialized view with arg_max. That is part 6 of this series.

Question 6: A query repeats the same filter in three places. How do let statements and stored functions help?

Inside one query, a let statement names the filter once. When many queries need it, a stored function saves it in the database, with parameters.

Let's start with let. errors is the error rows and slow is 2000 ms, and the query body uses both. We run q6.kql:

q6.kql
let errors = Requests | where Level == "Error";
let slow = 2000;
errors
| where DurationMs > slow
| summarize SlowErrors = count() by Service
| order by Service asc
Terminal
$ kql -f q6.kql
> let errors = Requests | where Level == "Error";
Service   SlowErrors
--------  ----------
payments  5

payments has 5 slow errors.

Now the stored function. .create-or-alter function saves ErrorsFor in the database, with a folder, a docstring, and a parameter with a default. Then anyone calls it like a table. We run q6_func.kql, then q6_use.kql:

q6_func.kql
.create-or-alter function with (folder="Reports",
    docstring="Error rows for one service")
    ErrorsFor(svc:string, minMs:long = 0) {
    Requests
    | where Level == "Error" and Service == svc
    | where DurationMs > minMs
}
q6_use.kql
ErrorsFor("payments")
| count

ErrorsFor("payments", 3000)
| project Timestamp, DurationMs
| order by Timestamp asc

.show functions
| project Name, Parameters, Folder
Terminal
$ kql -q -f q6_func.kql
> .create-or-alter function with (folder="Reports",
ok: .create-or-alter function with (folder="Reports",
$ kql -f q6_use.kql
> ErrorsFor("payments")
Count
-----
5

> ErrorsFor("payments", 3000)
Timestamp             DurationMs
--------------------  ----------
2026-10-20T08:12:00Z  3050
2026-10-20T09:10:00Z  3010
2026-10-20T11:44:00Z  3100

> .show functions
Name       Parameters                  Folder
---------  --------------------------  -------
ErrorsFor  (svc:string, minMs:long=0)  Reports

ErrorsFor("payments") returns 5 rows. With a minimum of 3000 ms, 3. .show functions lists it with its parameters, (svc:string, minMs:long=0), and the folder Reports.

One subtlety: let binds a name to a calculation, not to its value. Reference it twice and it can run twice, so wrap heavy reuse in materialize(), or use toscalar() for a single value.

Real project

Functions are the contract for dashboards. Creating one needs Database User, altering it needs Function Admin, and the scripts live in source control.

Scenario 7: Support asks for the top ten customers with the most failed requests today and their error rate. How do you write it?

Filter to today first, then summarize per customer: the total with count(), and the failures with countif(). Then compute the rate with real division, and sort with a tie-breaker.

With today pinned to October 20, we run q7.kql:

q7.kql
let today = datetime(2026-10-20);   // pinned "today"
Requests
| where Timestamp >= today and Timestamp < today + 1d
| summarize Total = count(),
    Failed = countif(Level == "Error") by CustomerId
| extend ErrorRate = round(100.0 * Failed / Total, 1)
| order by Failed desc, CustomerId asc
| take 10
Terminal
$ kql -f q7.kql
> let today = datetime(2026-10-20);   // pinned "today"
CustomerId  Total  Failed  ErrorRate
----------  -----  ------  ---------
C002        5      4       80
C004        3      2       66.7
C001        4      1       25
C003        3      0       0
C005        3      0       0
Result table5 rows
CustomerIdTotalFailedErrorRate
C0025480
C0043266.7
C0014125
C003300
C005300

C002 has 4 failures out of 5, 80 percent. C004 has 2 of 3, 66.7, and C001 1 of 4. Note the 100.0: without it, integer division gives 66, not 66.7.

C003 and C005 both have 0 failures: a tie. Without a second sort key, their order can change from run to run. So I order by Failed desc, then CustomerId, and take 10.

Why not top 10 by Failed? top takes only one sort expression. Let's give it a second one with q7_top.kql:

q7_top.kql
Requests
| summarize Failed = countif(Level == "Error") by CustomerId
| top 10 by Failed desc, CustomerId asc
Terminal (expected error)
$ kql -f q7_top.kql
> Requests
Error: Syntax error: SYN0002: The incomplete fragment is unexpected. [line:position=3:24]

The engine returns Syntax error: SYN0002: The incomplete fragment is unexpected.

On a live table, today is startofday(now()):

q7_live.kql (a live table)
// on a live table
let today = startofday(now());
Requests
| where Timestamp >= today
| summarize Total = count(),
    Failed = countif(Level == "Error") by CustomerId
| where Total >= 20                // a minimum volume
| extend ErrorRate = round(100.0 * Failed / Total, 1)
| order by Failed desc, CustomerId asc
| take 10
Minimum volume

One failure out of one request is 100 percent, and that customer isn't the real problem. So the live version keeps only customers with at least 20 requests.

Key points to remember

  • Time filter first, right after the table, then the value filter; pin now on sample data
  • project shapes, extend adds, project-away drops; long / long is integer division
  • summarize ... by bin(Timestamp, 1h): only groups that exist come back
  • has = whole term, uses the index; contains scans; == is case-sensitive
  • arg_max(Timestamp, *) for the latest row; let and stored functions name logic once

The full script

Here is a Python script that writes every file and runs every query in order on the Kusto emulator; start the emulator container first, as the comments at the top of the script show.

Show the full script that writes the files and runs every step
Full script - writes the files and runs every step on the Kusto emulator (start the emulator container first, see the comments at the top), run it with Python
# ========================================================================
# Azure Data Explorer Interview Q&A #2 - KQL query essentials
# TechBrothersIT. Names and sample data are invented for teaching.
# Every step below was run on the Kusto emulator (Kustainer, the Azure Data Explorer engine in a Linux container,
# build 2026.11.02); the outputs in the video and the blog post are the real outputs.
# To run it yourself (Docker on Linux, macOS or Windows with WSL), in an EMPTY folder:
#   1. Start the emulator with this folder mounted at /kustodata (the steps ingest files from there):
#      docker run -d --name kustainer -e ACCEPT_EULA=Y -m 4G -p 8080:8080 -v "$PWD":/kustodata \
#          mcr.microsoft.com/azuredataexplorer/kustainer-linux:latest
#      Wait until `docker logs kustainer` says the database "is answering queries".
#   2. python KQL_Interview_QA_02_Code.py   (Python 3.10 or later; kql.py itself needs no packages: it uses the REST API)
# "kql" in the commands is a small helper written by Step 0 (kql.py): it sends a query or a management command
# to http://localhost:8080 and prints the result as a text table (no sign-in: the emulator has no authentication).
# The emulator is for development and testing only; an Azure Data Explorer cluster needs Microsoft Entra ID sign-in
# (use the Kusto SDK or the web UI there).
# Steps that restart the emulator say so in a comment: run "docker restart kustainer" at that point.
# ========================================================================

import os, subprocess, sys, time

def write(path, text):
    if text is None:
        if os.path.exists(path): os.remove(path)
        return
    os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
    with open(path, "w", encoding="utf-8") as f:
        f.write(text.strip("\n") + "\n")

def run(cmd, expect_error=False, allow_error=False, env=None):
    print("\n$ " + cmd, flush=True)
    rc = subprocess.run(cmd, shell=True, env=dict(os.environ, **(env or {}))).returncode
    if rc != 0 and not (expect_error or allow_error):
        raise SystemExit("stopped: this command was expected to succeed")

# Environment used by every step (the database the steps use)
os.environ.update({'KQL_DB': 'shop'})

# Step 0: the kql helper, the database and the starting files
write("kql.py", '"""kql: run KQL queries and management commands on the local Kusto emulator (Kustainer), print the results as text tables.\n  kql \'StormEvents | take 3\'              run one query or one management command (starts with \'.\')\n  kql -f script.kql                      run every statement in the file (statements are separated by blank lines;\n                                         // comment lines are skipped); each result is printed under its first line\n  options: -d DB (default $KQL_DB or NetDefaultDB)  --max N rows to print (default 30)  --cell N max chars per cell (default 60)\n           -q  for management commands: print one line ("ok: <command>") instead of the result table\n           --stats  after a query, print the rows / extents the query scanned (from the engine\'s own query statistics)\n           --props \'{"Options": {...}}\'  client request properties (e.g. truncationmaxrecords)\n           -e  keep going after an error (the error is printed and the exit code is 1 at the end)\n           --json  print the last statement\'s first result as JSON {"cols": [...], "rows": [...]} (used by the video generator)\nEmpty cells: a null value in a non-string column prints as "null"; an empty or null string prints as nothing.\nA query that fails after returning some rows (for example a result over the truncation limit) prints the rows it got, then\n"Error (partial result): <engine message>", and exits 1.\nThe engine answers at http://localhost:8080 (no authentication: the emulator has none)."""\nimport json, os, re, sys, urllib.request, urllib.error\n\nURL = os.environ.get(\'KQL_URL\', \'http://localhost:8080\')\n\n\ndef post(path, db, csl, props=None):\n    body = {\'db\': db, \'csl\': csl, \'properties\': json.dumps(props or {\'Options\': {}})}\n    req = urllib.request.Request(URL + path, data=json.dumps(body).encode(), method=\'POST\',\n                                 headers={\'Content-Type\': \'application/json; charset=utf-8\', \'Accept\': \'application/json\',\n                                          \'x-ms-app\': \'kql-cli\', \'x-ms-client-request-id\': \'kql-cli\'})\n    opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))\n    try:\n        with opener.open(req, timeout=600) as r: return json.loads(r.read().decode(\'utf-8\'))\n    except urllib.error.HTTPError as e:\n        txt = e.read().decode(\'utf-8\', \'replace\')\n        try:\n            er = json.loads(txt)[\'error\']\n            msg = er.get(\'@message\') or er.get(\'message\')\n        except Exception:\n            msg = txt.strip().split(\'\\n\')[0]\n        msg = re.sub(r\'^Request is invalid and cannot be processed: \', \'\', msg)\n        msg = re.sub(r\'\\s*\\(Activity stack:.*\', \'\', msg, flags=re.S)\n        raise KqlError(msg.strip())\n\n\nclass KqlError(Exception): pass\n\n\ndef cell(v, width, typ=\'\'):\n    if v is None: s = \'\' if typ in (\'string\', \'\') else \'null\'\n    elif isinstance(v, bool): s = \'true\' if v else \'false\'\n    elif isinstance(v, (dict, list)): s = json.dumps(v, separators=(\',\', \':\'), ensure_ascii=False)\n    elif isinstance(v, float):\n        s = repr(v)\n        if s.endswith(\'.0\'): s = s[:-2]\n    else: s = str(v)\n    s = s.replace(\'\\r\', \' \').replace(\'\\n\', \' \')\n    if len(s) > width: s = s[:width - 3] + \'...\'\n    return s\n\n\ndef table(cols, rows, mx, width, types=None):\n    rows = rows if isinstance(rows, list) else []\n    types = types or [\'\'] * len(cols)\n    shown = [[cell(v, width, types[i]) for i, v in enumerate(r)] for r in rows[:mx]]\n    w = [max([len(c)] + [len(r[i]) for r in shown]) for i, c in enumerate(cols)]\n    out = [\'  \'.join(c.ljust(w[i]) for i, c in enumerate(cols)).rstrip(),\n           \'  \'.join(\'-\' * w[i] for i in range(len(cols)))]\n    for r in shown:\n        out.append(\'  \'.join(v.ljust(w[i]) for i, v in enumerate(r)).rstrip())\n    n = len(rows)\n    if n > mx: out.append(\'... %d more rows (%d rows in total)\' % (n - mx, n))\n    elif n == 0: out.append(\'(no rows)\')\n    return \'\\n\'.join(out)\n\n\nLAST = {}\n\n\ndef one_api_msg(errs):\n    msgs = []\n    for e in errs or []:\n        er = e.get(\'error\', e) if isinstance(e, dict) else {}\n        m = er.get(\'@message\') or er.get(\'message\') or str(e)\n        m = re.sub(r\'\\s*\\(Activity stack:.*\', \'\', m, flags=re.S).strip()\n        if m not in msgs: msgs.append(m)\n    return \' | \'.join(msgs) or \'unknown error\'\n\n\ndef run_query(db, csl, a):\n    frames = post(\'/v2/rest/query\', db, csl, a.props)\n    outs, stats, partial = [], None, None\n    for f in frames:\n        if f.get(\'FrameType\') == \'DataSetCompletion\' and f.get(\'HasErrors\') and partial is None:\n            partial = one_api_msg(f.get(\'OneApiErrors\'))\n        if f.get(\'FrameType\') != \'DataTable\': continue\n        if f.get(\'TableKind\') == \'PrimaryResult\':\n            rows = [r for r in f[\'Rows\'] if isinstance(r, list)]\n            for r in f[\'Rows\']:\n                if isinstance(r, dict) and r.get(\'OneApiErrors\'): partial = one_api_msg(r[\'OneApiErrors\'])\n            cols = [c[\'ColumnName\'] for c in f[\'Columns\']]\n            if \'cols\' not in LAST: LAST.update(cols=cols, rows=rows)\n            outs.append(table(cols, rows, a.max, a.cell, [str(c.get(\'ColumnType\', \'\')).lower() for c in f[\'Columns\']]))\n        if f.get(\'TableKind\') == \'QueryCompletionInformation\':\n            cols = [c[\'ColumnName\'] for c in f[\'Columns\']]\n            for r in f[\'Rows\']:\n                d = dict(zip(cols, r))\n                if d.get(\'EventTypeName\') == \'QueryResourceConsumption\':\n                    try: stats = json.loads(d[\'Payload\'])\n                    except Exception: pass\n                if d.get(\'Level\') in (2, 3) and d.get(\'EventTypeName\') not in (\'QueryResourceConsumption\',):\n                    txt = d.get(\'Payload\') or \'\'\n                    try: txt = json.loads(txt).get(\'Text\', txt)\n                    except Exception: pass\n                    outs.append(\'%s: %s\' % (\'Warning\' if d.get(\'Level\') == 3 else \'Error\', txt))\n    if a.stats and stats:\n        sh = (stats.get(\'resource_usage\') or {}).get(\'cache\', {}).get(\'shards\', {})\n        ix = stats.get(\'input_dataset_statistics\') or {}\n        ex, rw = ix.get(\'extents\') or {}, ix.get(\'rows\') or {}\n        outs.append(\'scanned: %s of %s extents, %s of %s rows\' % (ex.get(\'scanned\'), ex.get(\'total\'), rw.get(\'scanned\'), rw.get(\'total\')))\n    if partial:\n        outs.append(\'Error (partial result): \' + partial)\n        raise PartialError(\'\\n\\n\'.join(outs))\n    return \'\\n\\n\'.join(outs)\n\n\nclass PartialError(Exception): pass\n\n\ndef run_mgmt(db, csl, a):\n    d = post(\'/v1/rest/mgmt\', db, csl, a.props)\n    if a.q and not a.json: return \'ok: \' + first_line(csl)\n    t = (d.get(\'Tables\') or [{}])[0]\n    LAST.update(cols=[c[\'ColumnName\'] for c in t.get(\'Columns\', [])], rows=t.get(\'Rows\', []))\n    return table([c[\'ColumnName\'] for c in t.get(\'Columns\', [])], t.get(\'Rows\', []), a.max, a.cell,\n                 [str(c.get(\'DataType\', c.get(\'ColumnType\', \'\'))).lower().replace(\'system.\', \'\') for c in t.get(\'Columns\', [])])\n\n\ndef first_line(s):\n    l = s.strip().split(\'\\n\')[0].strip()\n    return l if len(l) <= 90 else l[:87] + \'...\'\n\n\ndef statements(text):\n    blocks, cur = [], []\n    for l in text.split(\'\\n\'):\n        if not l.strip():\n            if cur: blocks.append(\'\\n\'.join(cur)); cur = []\n            continue\n        if l.strip().startswith(\'//\') and not cur: continue\n        cur.append(l)\n    if cur: blocks.append(\'\\n\'.join(cur))\n    return blocks\n\n\ndef main():\n    import argparse\n    p = argparse.ArgumentParser(add_help=False)\n    p.add_argument(\'-d\', default=os.environ.get(\'KQL_DB\', \'NetDefaultDB\'))\n    p.add_argument(\'-f\'); p.add_argument(\'-q\', action=\'store_true\'); p.add_argument(\'-e\', action=\'store_true\')\n    p.add_argument(\'--stats\', action=\'store_true\'); p.add_argument(\'--max\', type=int, default=30)\n    p.add_argument(\'--cell\', type=int, default=60); p.add_argument(\'--props\'); p.add_argument(\'-h\', \'--help\', action=\'store_true\')\n    p.add_argument(\'--json\', action=\'store_true\')\n    p.add_argument(\'text\', nargs=\'?\')\n    a = p.parse_args()\n    if a.help or (not a.f and not a.text): print(__doc__); return 0\n    a.props = json.loads(a.props) if a.props else None\n    stmts = statements(open(a.f).read()) if a.f else [a.text]\n    rc, first = 0, True\n    if a.json:\n        for s in stmts:\n            LAST.clear()\n            try:\n                run_mgmt(a.d, s, a) if s.lstrip().startswith(\'.\') else run_query(a.d, s, a)\n            except (KqlError, PartialError) as e:\n                print(\'Error: \' + str(e)); return 1\n        print(json.dumps(dict(cols=LAST.get(\'cols\', []), rows=LAST.get(\'rows\', [])), ensure_ascii=False))\n        return 0\n    for s in stmts:\n        if a.f:\n            if not first: print()\n            print(\'> \' + first_line(s))\n        first = False\n        try:\n            out = run_mgmt(a.d, s, a) if s.lstrip().startswith(\'.\') else run_query(a.d, s, a)\n            if out: print(out)\n        except PartialError as e:\n            print(str(e)); rc = 1\n            if not a.e: return 1\n        except KqlError as e:\n            print(\'Error: \' + str(e)); rc = 1\n            if not a.e: return 1\n    return rc\n\n\nif __name__ == \'__main__\':\n    sys.exit(main())\n')
KQL = '"%s" "%s"' % (sys.executable, os.path.abspath("kql.py"))   # commands below run it as: python kql.py ...
run(KQL + ' -d NetDefaultDB -q \'.create database shop persist (@"/kustodata/dbs/shop/md", @"/kustodata/dbs/shop/data")\'')
write('requests_part1.csv', """\
2026-10-20T08:05:00Z,Info,checkout,C001,120,order placed
2026-10-20T08:12:00Z,Error,payments,C002,3050,db-timeout while charging card
2026-10-20T08:20:00Z,Info,catalog,C003,45,search ok
2026-10-20T08:41:00Z,Warning,checkout,C001,900,slow response from payments.api
2026-10-20T08:55:00Z,Error,checkout,C004,1500,Error: payment declined
2026-10-20T09:02:00Z,Info,catalog,C002,50,search ok
""")
write('requests_part2.csv', """\
2026-10-20T09:10:00Z,Error,payments,C002,3010,db-timeout while charging card
2026-10-20T09:15:00Z,Info,checkout,C005,130,order placed
2026-10-20T09:31:00Z,Error,payments,C001,2900,ERROR connecting to db.primary
2026-10-20T09:47:00Z,Info,catalog,C003,40,search ok
2026-10-20T11:03:00Z,Info,checkout,C003,110,order placed
2026-10-20T11:09:00Z,Error,checkout,C002,1700,user error: invalid coupon
""")
write('requests_part3.csv', """\
2026-10-20T11:20:00Z,Warning,catalog,C005,700,cache miss
2026-10-20T11:44:00Z,Error,payments,C004,3100,err code 42 from gateway
2026-10-20T11:58:00Z,Info,payments,C005,200,refund issued
2026-10-20T12:06:00Z,Error,payments,C002,2950,db-timeout while charging card
2026-10-20T12:30:00Z,Info,checkout,C001,125,order placed
2026-10-20T12:52:00Z,Info,catalog,C004,48,search ok
""")
write('setup.kql', """\
.create table Requests (Timestamp:datetime, Level:string, Service:string,
    CustomerId:string, DurationMs:long, Message:string)

.ingest into table Requests (@"/kustodata/requests_part1.csv") with (format="csv")

.ingest into table Requests (@"/kustodata/requests_part2.csv") with (format="csv")

.ingest into table Requests (@"/kustodata/requests_part3.csv") with (format="csv")

.create table Telemetry (Timestamp:datetime, DeviceId:string, Temp:real,
    Battery:long, Status:string)

.ingest inline into table Telemetry <|
2026-10-20T08:00:00Z,dev-01,21.4,98,ok
2026-10-20T08:00:00Z,dev-02,19.8,76,ok
2026-10-20T08:00:00Z,dev-03,23.1,54,ok
2026-10-20T08:15:00Z,dev-01,21.9,97,ok
2026-10-20T08:15:00Z,dev-02,20.4,75,ok
2026-10-20T08:30:00Z,dev-01,22.6,97,ok
2026-10-20T08:30:00Z,dev-03,27.9,41,warn
2026-10-20T08:45:00Z,dev-02,20.1,74,ok
""")
run(KQL + ' -q -f setup.kql')

# Step 1: The last hour of errors, with "now" pinned to 12:10 on the sample day
write('q1.kql', """\
let now_ = datetime(2026-10-20 12:10);   // pinned "now"
Requests
| where Timestamp between (now_ - 1h .. now_)
| where Level == "Error"
| project Timestamp, Service, Message
| order by Timestamp asc
""")
run(KQL + ' -f q1.kql')

# Step 2: extend adds columns, project-away drops columns
write('q2.kql', """\
Requests
| where Level == "Error"
| extend Seconds = DurationMs / 1000.0,
    Slow = DurationMs > 2000
| project-away Message, CustomerId
| order by Timestamp asc
""")
run(KQL + ' -f q2.kql')

# Step 3: Integer division: a long divided by a long
write('q2b.kql', """\
Requests
| where Service == "checkout"
| project Timestamp, DurationMs,
    Sec = DurationMs / 1000, Seconds = DurationMs / 1000.0
| order by Timestamp asc
""")
run(KQL + ' -f q2b.kql')

# Step 4: Requests and errors per hour per service with summarize and bin
write('q3.kql', """\
Requests
| summarize Requests = count(),
    Errors = countif(Level == "Error")
    by bin(Timestamp, 1h), Service
| order by Timestamp asc, Service asc
""")
run(KQL + ' -f q3.kql')

# Step 5: has matches whole terms, contains matches substrings
write('q4.kql', """\
Requests
| where Message contains "err"
| project Message,
    HasErr = Message has "err",
    HasError = Message has "error"
| order by Message asc
""")
run(KQL + ' -f q4.kql')

# Step 6: Counting has, contains, == and =~ over all rows
write('q4b.kql', """\
Requests
| summarize HasError = countif(Message has "error"),
    HasErr = countif(Message has "err"),
    ContainsErr = countif(Message contains "err"),
    LevelEq = countif(Level == "error"),
    LevelEqi = countif(Level =~ "error")
""")
run(KQL + ' -f q4b.kql')

# Step 7: max(Timestamp) per device: the time only
write('q5a.kql', """\
Telemetry
| summarize Last = max(Timestamp) by DeviceId
| order by DeviceId asc
""")
run(KQL + ' -f q5a.kql')

# Step 8: arg_max(Timestamp, *) per device: the whole latest row
write('q5.kql', """\
Telemetry
| summarize arg_max(Timestamp, *) by DeviceId
| order by DeviceId asc
""")
run(KQL + ' -f q5.kql')

# Step 9: let statements name a filter and a value once
write('q6.kql', """\
let errors = Requests | where Level == "Error";
let slow = 2000;
errors
| where DurationMs > slow
| summarize SlowErrors = count() by Service
| order by Service asc
""")
run(KQL + ' -f q6.kql')

# Step 10: A stored function with a folder, a docstring and a default parameter
write('q6_func.kql', """\
.create-or-alter function with (folder="Reports",
    docstring="Error rows for one service")
    ErrorsFor(svc:string, minMs:long = 0) {
    Requests
    | where Level == "Error" and Service == svc
    | where DurationMs > minMs
}
""")
write('q6_use.kql', """\
ErrorsFor("payments")
| count

ErrorsFor("payments", 3000)
| project Timestamp, DurationMs
| order by Timestamp asc

.show functions
| project Name, Parameters, Folder
""")
run(KQL + ' -q -f q6_func.kql')
run(KQL + ' -f q6_use.kql')

# Step 11: Top ten customers by failed requests today, with the error rate and a tie-breaker
write('q7.kql', """\
let today = datetime(2026-10-20);   // pinned "today"
Requests
| where Timestamp >= today and Timestamp < today + 1d
| summarize Total = count(),
    Failed = countif(Level == "Error") by CustomerId
| extend ErrorRate = round(100.0 * Failed / Total, 1)
| order by Failed desc, CustomerId asc
| take 10
""")
run(KQL + ' -f q7.kql')

# Step 12: top accepts one sort expression: a second one is a syntax error
write('q7_top.kql', """\
Requests
| summarize Failed = countif(Level == "Error") by CustomerId
| top 10 by Failed desc, CustomerId asc
""")
run(KQL + ' -f q7_top.kql', expect_error=True)   # EXPECTED TO FAIL: the video shows this error
Next in this series

Part 3: Joins, Unions and Lookups

The sample data on this page is invented for teaching. Every terminal output is the real output of the command above it, run on the Kusto emulator (Kustainer, the Azure Data Explorer engine in a Linux container, build 2026.11.02). Features the emulator cannot show truthfully are shown as code. Part of the Real Time Azure Data Explorer and KQL Interview Questions and Answers series on TechBrothersIT. Features, limits and their status (GA or preview) are current as of November 2026; check the official Azure Data Explorer and Kusto documentation (learn.microsoft.com/azure/data-explorer and learn.microsoft.com/kusto) for anything you quote in an interview.

No comments:

Post a Comment

Note: Only a member of this blog may post a comment.