Azure Data Explorer and KQL Interview Questions and Answers Part 9 - Operations, Monitoring and Integration

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

In this post we are going to cover operations, monitoring and integration in Azure Data Explorer. 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: data stopped arriving in a table for two hours and nobody noticed.

Operations is everything after the cluster is built: knowing it's healthy, finding failed loads, reading the lake, connecting tools, exporting data, and shipping schema changes safely. Interviewers ask about it because this is the work of a running platform, and the questions show quickly who has been on call for one.

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

The short answer

Say this first

Watch Azure Monitor metrics (keep alive, CPU, cache utilization factor, ingestion latency and result) with alerts. .show ingestion failures explains failed loads, external tables query the lake in place, connectors send KQL to stored functions, continuous export uses database cursors, schema ships as idempotent scripts, and a freshness check per table catches silence.

Let's create some sample data

Every query below runs on the Kusto emulator, Microsoft's Azure Data Explorer engine in a local container, in a database called shop. kql is a small helper that sends a query or a .kql file to the emulator and prints the result as a table. setup.kql creates three small invented tables: Sales, six orders on October 20, Telemetry, three device readings, and an empty Orders table. Both loads use .ingest inline, which is for exploration and prototyping, not production. What only a cluster in Azure can show, such as Azure Monitor metrics and alerts, storage accounts and deployments, is shown as code.

setup.kql
.create table Sales (OrderId:long, Customer:string, Amount:real, OrderTime:datetime)

.ingest inline into table Sales <|
5001,C001,25.5,2026-10-20T08:05:00Z
5002,C002,14,2026-10-20T08:40:00Z
5003,C001,40,2026-10-20T09:15:00Z
5004,C003,9.5,2026-10-20T10:20:00Z
5005,C002,60,2026-10-20T10:55:00Z
5006,C004,33,2026-10-20T11:50:00Z

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

.ingest inline into table Telemetry <|
d1,21.5,2026-10-20T09:00:00Z
d2,22,2026-10-20T09:30:00Z
d1,21.8,2026-10-20T09:55:00Z

.create table Orders (OrderId:long, Customer:string, Amount:real)

Question 1: Your manager asks how you know the cluster is healthy. What do you monitor?

Azure Monitor metrics, with alerts on the few that matter. Metrics tell you that something is wrong; diagnostic logs and the .show commands tell you why.

Let's go through them in order. Keep alive tells you the cluster answers queries. CPU and the cache utilization factor tell you about capacity: above 100 percent, the hot data no longer fits the cache. For ingestion, we watch ingestion latency, from received to ready for query, the ingestion result, and ingestion utilization. With data connections, discovery latency shows a backlog building before data is late. For queries, throttled queries and query duration.

MetricWhat it tells youAlert when
Keep alivethe cluster answers queriesbelow 1
CPUcompute pressurehigh for a long time
Cache utilization factorhot data vs cache (100% is optimal)above 100%
Ingestion latencyreceived to ready for queryabove your target
Ingestion resultsuccesses and failuresany failures
Ingestion utilizationingestion capacity in usehigh for a long time
Discovery latencydata connection backloggrowing
Throttled queriesrequests rejected by limitsabove 0

And if you use them:

  • Materialized views: age and health
  • Continuous export: pending and lateness
  • The why: diagnostic logs, .show commands

I alert on keep alive, ingestion failures, ingestion latency and throttling, routed to an action group. The emulator has no Azure Monitor, so this one is code:

alerts.sh (Azure, not run)
# the cluster stops answering queries
az monitor metrics alert create -n adx-keepalive -g rg-data \
  --scopes "$ADX_ID" --condition "avg KeepAlive < 1" \
  --window-size 5m --evaluation-frequency 1m --action "$AG_ID"
# data takes longer than 10 minutes to become queryable
az monitor metrics alert create -n adx-ingest-latency -g rg-data \
  --scopes "$ADX_ID" \
  --condition "avg IngestionLatencyInSeconds > 600" \
  --window-size 15m --evaluation-frequency 5m --action "$AG_ID"
Pitfall

Alerting on CPU alone. A busy cluster is fine; late data is not.

Question 2: Someone asks how to find ingestion failures. Which commands do you use?

.show ingestion failures tells you why a load failed, with the failure kind and the error code. But first know this: a failed ingestion command doesn't always raise an error.

Let's prove it. We load three files into Orders with .ingest into: one has a short row, one is CSV loaded as Parquet, and one doesn't exist. Then we count the table.

ingest.kql
.ingest into table Orders (@"/kustodata/orders_1001.csv")
    with (format='csv', ignoreFirstRecord=true)
    | project ItemLoaded, HasErrors

.ingest into table Orders (@"/kustodata/orders_1002.csv")
    with (format='parquet')
    | project ItemLoaded, HasErrors

.ingest into table Orders (@"/kustodata/orders_1003.csv")
    with (format='csv')
    | project ItemLoaded, HasErrors

Orders | count
orders_1001.csv
OrderId,Customer,Amount
6001,C004,12
6002,C005
orders_1002.csv
6003,C006,20
6004,C001,18
Terminal
$ kql -f ingest.kql
> .ingest into table Orders (@"/kustodata/orders_1001.csv")
ItemLoaded                  HasErrors
--------------------------  ---------
/kustodata/orders_1001.csv  1

> .ingest into table Orders (@"/kustodata/orders_1002.csv")
ItemLoaded                  HasErrors
--------------------------  ---------
/kustodata/orders_1002.csv  1

> .ingest into table Orders (@"/kustodata/orders_1003.csv")
ItemLoaded                  HasErrors
--------------------------  ---------
/kustodata/orders_1003.csv  1

> Orders | count
Count
-----
0

No error. Each load returns HasErrors 1, which means true, and Orders has 0 rows.

Failures show up a little later on the emulator, so the full script waits at this point until all three are listed. Then we read them:

Terminal
$ kql '.show ingestion failures | where Table == "Orders" | project Source = IngestionSourcePath, FailureKind, ErrorCode | order by Source asc'
Source                      FailureKind  ErrorCode
--------------------------  -----------  --------------------------
/kustodata/orders_1001.csv  Permanent    Stream_WrongNumberOfFields
/kustodata/orders_1002.csv  Permanent    BadRequest_InvalidBlob
/kustodata/orders_1003.csv  Permanent    Download_SourceNotFound
Result table3 rows
SourceFailureKindErrorCode
/kustodata/orders_1001.csvPermanentStream_WrongNumberOfFields
/kustodata/orders_1002.csvPermanentBadRequest_InvalidBlob
/kustodata/orders_1003.csvPermanentDownload_SourceNotFound

Each file is there, all Permanent: orders_1001.csv with Stream_WrongNumberOfFields, orders_1002.csv with BadRequest_InvalidBlob, and orders_1003.csv with Download_SourceNotFound.

Now the catch. This command covers only one stage:

WhereCovers
.show ingestion failuresthe management-command stage only, 14 days
Ingestion result metric + FailedIngestion logevery stage, queued and data connections too
.show operations <id>the state of one async command

A transient failure can work on a retry; a permanent one needs a fix to the data or the mapping. Also check OriginatesFromUpdatePolicy: the file can be fine while the update policy fails. For one async command, .show operations with its operation ID gives the state.

Real project

I summarize failures by error code per day and alert when a new error code shows up.

Question 3: A teammate asks how to query data in the lake without ingesting it. How do external tables work?

An external table is a schema over files in storage, like CSV, Parquet or Delta in ADLS, or over a SQL database. The data stays where it is, and you query it with external_table().

Let's create OrdersLake over two day folders. The partition column, Day, comes from the path format, not from the files, so a filter on Day reads only that folder. We query October 2.

lake.kql
.create external table OrdersLake (OrderId:long, Customer:string,
    Amount:real)
    kind=storage partition by (Day:datetime)
    pathformat=('day=' datetime_pattern('yyyy-MM-dd', Day))
    dataformat=csv
    ( h@'/kustodata/lake/orders' )

external_table('OrdersLake')
| where Day == datetime(2026-10-02)
| order by OrderId asc
lake/orders/day=2026-10-01/part-0.csv
5001,C001,25.5
5002,C002,14
lake/orders/day=2026-10-02/part-0.csv
5003,C001,40
5004,C003,9.5
5005,C002,60
Terminal
$ kql -q -f lake.kql
> .create external table OrdersLake (OrderId:long, Customer:string,
ok: .create external table OrdersLake (OrderId:long, Customer:string,

> external_table('OrdersLake')
OrderId  Customer  Amount  Day
-------  --------  ------  --------------------
5003     C001      40      2026-10-02T00:00:00Z
5004     C003      9.5     2026-10-02T00:00:00Z
5005     C002      60      2026-10-02T00:00:00Z
Result table3 rows
OrderIdCustomerAmountDay
5003C001402026-10-02T00:00:00Z
5004C0039.52026-10-02T00:00:00Z
5005C002602026-10-02T00:00:00Z

October 2 returns its three orders, 5003, 5004 and 5005, each with Day 2026-10-02T00:00:00Z taken from the folder name.

Next, .show external table OrdersLake artifacts lists the files behind it, with the partition each one maps to.

Terminal
$ kql '.show external table OrdersLake artifacts | project Uri, Partition | order by Uri asc'
Uri                                               Partition
------------------------------------------------  --------------------------------------
/kustodata/lake/orders/day=2026-10-01/part-0.csv  {"Day":"2026-10-01T00:00:00.0000000Z"}
/kustodata/lake/orders/day=2026-10-02/part-0.csv  {"Day":"2026-10-02T00:00:00.0000000Z"}
Result table2 rows
UriPartition
/kustodata/lake/orders/day=2026-10-01/part-0.csv{"Day": "2026-10-01T00:00:00.0000000Z"}
/kustodata/lake/orders/day=2026-10-02/part-0.csv{"Day": "2026-10-02T00:00:00.0000000Z"}

Two files, day=2026-10-01/part-0.csv and day=2026-10-02/part-0.csv, each mapped to its Day.

In Azure, the path is a storage URL, and the cluster's managed identity reads it, once its policy allows external tables:

orders_lake.kql (Azure, not run)
// in Azure: a storage URL, read with the cluster managed identity
// (its managed_identity policy must allow the ExternalTable usage)
.create external table OrdersLake (OrderId:long, Customer:string,
    Amount:real)
    kind=storage partition by (Day:datetime)
    pathformat=('day=' datetime_pattern('yyyy-MM-dd', Day))
    dataformat=parquet (
h@'https://stshop.blob.core.windows.net/lake;managed_identity=system'
    )

When to use it:

  • Good for: archives, occasional joins, backfills with .set-or-append
  • Slower than ingested data: and results aren't cached
  • Hot dashboards: ingest the data instead
  • Delta tables: a query acceleration policy can help

Question 4: Someone asks how Data Factory, Power BI and Grafana connect to Azure Data Explorer. What do you use?

All three have built-in connectors that send KQL to the cluster. Each one signs in with its own identity.

ToolHow it connectsIdentity
Data Factorylinked service: Copy source and sink, Lookupservice principal or managed identity
Power BIAzure Data Explorer connector: Import or DirectQueryMicrosoft Entra ID sign-in
GrafanaAzure Data Explorer Datasource pluginManaged Grafana identity: database Viewer
Spark, Databricksopen-source Spark connectorEntra ID app (service principal)

Power BI uses Import for smaller tables and DirectQuery for large, fresh fact tables. Grafana's plugin is preinstalled in Azure Managed Grafana.

My habit: every tool calls a stored function, not raw tables. Let's create RevenuePerHour, which takes a time range, in a Dashboards folder with a docstring. Power BI and Grafana pass their own range, and the logic lives in one place, under version control. We call it for 08:00 to 11:59 on October 20.

dash.kql
.create-or-alter function with (folder='Dashboards',
    docstring='Orders and revenue per hour')
    RevenuePerHour(fromTime:datetime, toTime:datetime) {
    Sales
    | where OrderTime between (fromTime .. toTime)
    | summarize Orders = count(), Revenue = sum(Amount)
        by Hour = bin(OrderTime, 1h)
}

RevenuePerHour(datetime(2026-10-20 08:00), datetime(2026-10-20 11:59))
| order by Hour asc
Terminal
$ kql -q -f dash.kql
> .create-or-alter function with (folder='Dashboards',
ok: .create-or-alter function with (folder='Dashboards',

> RevenuePerHour(datetime(2026-10-20 08:00), datetime(2026-10-20 11:59))
Hour                  Orders  Revenue
--------------------  ------  -------
2026-10-20T08:00:00Z  2       39.5
2026-10-20T09:00:00Z  1       40
2026-10-20T10:00:00Z  2       69.5
2026-10-20T11:00:00Z  1       33
Result table4 rows
HourOrdersRevenue
2026-10-20T08:00:00Z239.5
2026-10-20T09:00:00Z140
2026-10-20T10:00:00Z269.5
2026-10-20T11:00:00Z133

Four hours: 2 orders and 39.5 at 08:00, 1 and 40 at 09:00, 2 and 69.5 at 10:00, and 1 and 33 at 11:00.

Watch the limits

A Data Factory copy from Azure Data Explorer truncates at 500,000 records or 64 MB unless the query sets notruncation, and its query timeout defaults to 10 minutes.

Question 5: A teammate needs query results exported to storage on a schedule. How does continuous export work?

Continuous export runs a query on an interval and writes the results to an external table, as CSV, TSV, JSON or Parquet. Database cursors make each run export only the rows that arrived since the last one.

Here is the flow:

  • Sales: new rows since the cursor
  • Continuous export: every 10 minutes
  • External table: Parquet files in storage

The cursor gives exactly once for the files it reports. That's why the query has no time filter: the cursor does that job. The source needs the ingestion time policy, which new tables have by default, and you need Database Admin to create the export.

Let's define it on the emulator: a Parquet external table, SalesExport, and an export of Sales every 10 minutes. Then .show continuous-exports lists it.

export.kql
.create external table SalesExport (OrderId:long, Customer:string,
    Amount:real, OrderTime:datetime)
    kind=storage dataformat=parquet
    ( h@'/kustodata/export/sales' )

.create-or-alter continuous-export SalesToLake
    over (Sales) to table SalesExport
    with (intervalBetweenRuns=10m)
    <| Sales
Terminal
$ kql -q -f export.kql
> .create external table SalesExport (OrderId:long, Customer:string,
ok: .create external table SalesExport (OrderId:long, Customer:string,

> .create-or-alter continuous-export SalesToLake
ok: .create-or-alter continuous-export SalesToLake
KQL - the query behind the result table
.show continuous-exports | project Name, ExternalTableName, Query, IntervalBetweenRuns, IsDisabled
Result table1 row
NameExternalTableNameQueryIntervalBetweenRunsIsDisabled
SalesToLakeSalesExportSales00:10:000

SalesToLake writes to SalesExport, runs the query Sales every 00:10:00, and IsDisabled is 0, which means false. The export runs themselves don't work on the emulator, so we stop at the definition.

In Azure, the external table points to storage and the export runs with the cluster's managed identity:

export_azure.kql (Azure, not run)
// in Azure: SalesExport's storage URL ends in ;impersonate
.alter-merge database shop policy managed_identity
    ```[{"ObjectId": "system", "AllowedUsages": "AutomatedFlows"}]```

.create-or-alter continuous-export SalesToLake
    over (Sales) to table SalesExport
    with (intervalBetweenRuns=10m, managedIdentity="system")
    <| Sales

Three things to plan for:

  • Duplicates possible: after a failure mid-export
  • Disabled after 7 days: of permanent failures
  • Alert: continuous export metrics

Question 6: Someone asks how to deploy tables, functions and policies safely across environments. What do you use?

Treat the schema as code: idempotent scripts in git, deployed by a pipeline. Run the same script twice and nothing breaks.

First, .show database shop schema as csl script prints every table with its policies, and every function, external table and continuous export, as commands. So you keep them in git and compare environments.

Terminal
$ kql --cell 98 '.show database shop schema as csl script'
DatabaseSchemaScript
--------------------------------------------------------------------------------------------------
.create-merge table Sales (OrderId:long, Customer:string, Amount:real, OrderTime:datetime)
.create-merge table Telemetry (DeviceId:string, Temp:real, Timestamp:datetime)
.create-merge table Orders (OrderId:long, Customer:string, Amount:real)
.create-or-alter function with (folder = "Dashboards", docstring = "Orders and revenue per hour...
.create-or-alter external table OrdersLake (OrderId:long, Customer:string, Amount:real)  kind =...
.create-or-alter external table SalesExport (OrderId:long, Customer:string, Amount:real, OrderT...
.create-or-alter continuous-export SalesToLake over (Sales) to table SalesExport with (forcedLa...

The tables come back as .create-merge table for Sales, Telemetry and Orders, then .create-or-alter for the function, the two external tables OrdersLake and SalesExport, and the continuous export SalesToLake. .create-merge adds what's missing and never drops a column. .create-or-alter replaces a function or an export.

Then, .execute database script runs commands in order and stops at the first failure. Our small runner, deploy.py, sends three: create a Returns table, drop a table that doesn't exist, and create a function.

deploy.kql
.execute database script <|
.create-merge table Returns (OrderId:long, Reason:string)
.drop table OrdersStaging
.create-or-alter function ReturnsByReason() { Returns | count }
deploy.py
import sys
from azure.kusto.data import KustoClient, KustoConnectionStringBuilder

# a tiny deploy runner; emulator only: no sign-in
client = KustoClient(KustoConnectionStringBuilder.with_no_authentication(
    "http://localhost:8080"))
script = open(sys.argv[1]).read()
for row in client.execute_mgmt("shop", script).primary_results[0]:
    print(f'{row["CommandType"]:<22} {row["Result"]:<10} {row["Reason"]}')
names = [r["Name"] for r in
         client.execute_mgmt("shop", ".show functions").primary_results[0]]
print("ReturnsByReason deployed:", "ReturnsByReason" in names)
Terminal
$ python deploy.py deploy.kql
TableCreate            Completed
TableDrop              Failed     Entity ID 'OrdersStaging' of kind 'Table' was not found.
ReturnsByReason deployed: False

TableCreate is Completed. TableDrop Failed with Entity ID 'OrdersStaging' of kind 'Table' was not found. The function after it never ran: ReturnsByReason deployed: False. Nothing was rolled back, so the Returns table stays. Scripts must be safe to rerun.

In Azure, I deploy with a Bicep database script resource:

shop_db.bicep (excerpt, Azure, not run)
resource schema 'Microsoft.Kusto/clusters/databases/scripts@2023-05-02' = {
  parent: shopDb
  name: 'shop-schema'
  properties: {
    scriptContent: loadTextContent('shop_schema.kql')
    continueOnErrors: false          // the default
    forceUpdateTag: buildId          // a new value runs it again
  }
}

The options:

  • Bicep database scripts: .create, .create-or-alter, .create-merge, .alter, .alter-merge, .add only
  • Azure DevOps: Azure Data Explorer pipeline tools tasks
  • Delta Kusto: open-source community tool, not a supported Azure product

Scenario 7: Data stopped arriving in a table for two hours and nobody noticed. What do you put in place?

A freshness check per table that pages someone. The question isn't whether the cluster is up; it's whether each table gets new data within its normal delay.

Let's create Freshness. It finds the last event in each table and flags the ones older than the allowed lag. We pin now to noon on October 20 and allow 30 minutes.

freshness.kql
.create-or-alter function with (folder='Monitoring')
    Freshness(asOf:datetime, maxLag:timespan) {
    union withsource=TableName Sales, Telemetry
    | extend EventTime = coalesce(OrderTime, Timestamp)
    | summarize LastEvent = max(EventTime) by TableName
    | extend Lag = asOf - LastEvent
    | extend Stale = Lag > maxLag
}

Freshness(datetime(2026-10-20 12:00), 30m)
| order by TableName asc
Terminal
$ kql -q -f freshness.kql
> .create-or-alter function with (folder='Monitoring')
ok: .create-or-alter function with (folder='Monitoring')

> Freshness(datetime(2026-10-20 12:00), 30m)
TableName  LastEvent             Lag       Stale
---------  --------------------  --------  -----
Sales      2026-10-20T11:50:00Z  00:10:00  false
Telemetry  2026-10-20T09:55:00Z  02:05:00  true
Result table2 rows
TableNameLastEventLagStale
Sales2026-10-20T11:50:00Z00:10:00false
Telemetry2026-10-20T09:55:00Z02:05:00true

Sales last saw an order at 11:50, a lag of 00:10:00, so Stale is false. Telemetry stopped at 09:55, a lag of 02:05:00: two hours five minutes, Stale is true.

In production, I use now() and the ingestion time, with the ingestion time policy, on by default, and the allowed lag per table in a config table. A scheduled alert, like an Azure Monitor log alert or a Logic App, runs it every few minutes:

freshness_prod.kql (Azure, not run)
// production: ingestion time and a lag per table from a config table
union withsource=TableName Sales, Telemetry
| summarize LastIngest = max(ingestion_time()) by TableName
| lookup kind=inner (FreshnessConfig) on TableName
| extend Stale = now() - LastIngest > MaxLag
| where Stale

Around it, metric alerts on ingestion result failures, discovery latency, and events processed dropping to zero, plus the failed ingestion logs:

alert_no_events.sh (Azure, not run)
# the event hub data connection processes nothing for 30 minutes
az monitor metrics alert create -n adx-no-events -g rg-data \
  --scopes "$ADX_ID" --condition "total EventsProcessed < 1" \
  --window-size 30m --evaluation-frequency 5m --action "$AG_ID"

The full checklist:

  • Freshness check per table: a scheduled alert that pages someone
  • Metric alerts: ingestion result, discovery latency, events processed = 0
  • Compare: event hub incoming vs events processed
  • Runbook: who is paged, where to look, how to backfill
  • Test it: pause a feed and wait for the page

Key points to remember

  • Metrics + alerts: keep alive, cache utilization factor, ingestion latency and result, throttling
  • .show ingestion failures: management-command loads, 14 days; metrics and logs for the rest
  • External tables query the lake in place; ingest what dashboards need
  • Tools call stored functions; continuous export uses database cursors, no time filter
  • Schema as idempotent scripts; a freshness check per table catches silence

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 #9 - Operations, monitoring and integration
# 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_09_Code.py   (Python 3.10 or later; kql.py itself needs no packages: it uses the REST API)
#      The python scripts in some steps use the Kusto Python SDK: pip install azure-kusto-data azure-kusto-ingest
# "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('setup.kql', """\
.create table Sales (OrderId:long, Customer:string, Amount:real, OrderTime:datetime)

.ingest inline into table Sales <|
5001,C001,25.5,2026-10-20T08:05:00Z
5002,C002,14,2026-10-20T08:40:00Z
5003,C001,40,2026-10-20T09:15:00Z
5004,C003,9.5,2026-10-20T10:20:00Z
5005,C002,60,2026-10-20T10:55:00Z
5006,C004,33,2026-10-20T11:50:00Z

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

.ingest inline into table Telemetry <|
d1,21.5,2026-10-20T09:00:00Z
d2,22,2026-10-20T09:30:00Z
d1,21.8,2026-10-20T09:55:00Z

.create table Orders (OrderId:long, Customer:string, Amount:real)
""")
run(KQL + ' -q -f setup.kql')

# Step 1: Three bad loads: a short row, CSV sent as Parquet, a missing file; no error, HasErrors true, table empty
write('ingest.kql', """\
.ingest into table Orders (@"/kustodata/orders_1001.csv")
    with (format='csv', ignoreFirstRecord=true)
    | project ItemLoaded, HasErrors

.ingest into table Orders (@"/kustodata/orders_1002.csv")
    with (format='parquet')
    | project ItemLoaded, HasErrors

.ingest into table Orders (@"/kustodata/orders_1003.csv")
    with (format='csv')
    | project ItemLoaded, HasErrors

Orders | count
""")
write('orders_1001.csv', """\
OrderId,Customer,Amount
6001,C004,12
6002,C005
""")
write('orders_1002.csv', """\
6003,C006,20
6004,C001,18
""")
run(KQL + ' -f ingest.kql')

# Step 2: (wait until the engine has flushed its ingestion failures)
run('for i in $(seq 1 60); do n=$(kql \'.show ingestion failures | where Table == "Orders" | count\' | tail -1); [ "$n" -ge 3 ] && break; sleep 5; done; echo ingestion failures flushed')

# Step 3: .show ingestion failures for the Orders table
run(KQL + ' \'.show ingestion failures | where Table == "Orders" | project Source = IngestionSourcePath, FailureKind, ErrorCode | order by Source asc\'')

# Step 4: A partitioned external table over day folders; a filter on Day reads one folder
write('lake.kql', """\
.create external table OrdersLake (OrderId:long, Customer:string,
    Amount:real)
    kind=storage partition by (Day:datetime)
    pathformat=('day=' datetime_pattern('yyyy-MM-dd', Day))
    dataformat=csv
    ( h@'/kustodata/lake/orders' )

external_table('OrdersLake')
| where Day == datetime(2026-10-02)
| order by OrderId asc
""")
write('lake/orders/day=2026-10-01/part-0.csv', """\
5001,C001,25.5
5002,C002,14
""")
write('lake/orders/day=2026-10-02/part-0.csv', """\
5003,C001,40
5004,C003,9.5
5005,C002,60
""")
run(KQL + ' -q -f lake.kql')

# Step 5: The files behind the external table and their partitions
run(KQL + " '.show external table OrdersLake artifacts | project Uri, Partition | order by Uri asc'")

# Step 6: A stored function as the contract for Power BI and Grafana
write('dash.kql', """\
.create-or-alter function with (folder='Dashboards',
    docstring='Orders and revenue per hour')
    RevenuePerHour(fromTime:datetime, toTime:datetime) {
    Sales
    | where OrderTime between (fromTime .. toTime)
    | summarize Orders = count(), Revenue = sum(Amount)
        by Hour = bin(OrderTime, 1h)
}

RevenuePerHour(datetime(2026-10-20 08:00), datetime(2026-10-20 11:59))
| order by Hour asc
""")
run(KQL + ' -q -f dash.kql')

# Step 7: Continuous export: a Parquet external table and an export of Sales every 10 minutes (definition only)
write('export.kql', """\
.create external table SalesExport (OrderId:long, Customer:string,
    Amount:real, OrderTime:datetime)
    kind=storage dataformat=parquet
    ( h@'/kustodata/export/sales' )

.create-or-alter continuous-export SalesToLake
    over (Sales) to table SalesExport
    with (intervalBetweenRuns=10m)
    <| Sales
""")
run(KQL + ' -q -f export.kql')

# Step 8: The whole database schema as a script of idempotent commands
run(KQL + " --cell 98 '.show database shop schema as csl script'")

# Step 9: .execute database script stops at the first failure; earlier commands stay, later ones never run
write('deploy.kql', """\
.execute database script <|
.create-merge table Returns (OrderId:long, Reason:string)
.drop table OrdersStaging
.create-or-alter function ReturnsByReason() { Returns | count }
""")
write('deploy.py', """\
import sys
from azure.kusto.data import KustoClient, KustoConnectionStringBuilder

# a tiny deploy runner; emulator only: no sign-in
client = KustoClient(KustoConnectionStringBuilder.with_no_authentication(
    "http://localhost:8080"))
script = open(sys.argv[1]).read()
for row in client.execute_mgmt("shop", script).primary_results[0]:
    print(f'{row["CommandType"]:<22} {row["Result"]:<10} {row["Reason"]}')
names = [r["Name"] for r in
         client.execute_mgmt("shop", ".show functions").primary_results[0]]
print("ReturnsByReason deployed:", "ReturnsByReason" in names)
""")
run('python deploy.py deploy.kql')

# Step 10: A freshness function: last event per table vs an allowed lag, with now pinned to 2026-10-20 12:00
write('freshness.kql', """\
.create-or-alter function with (folder='Monitoring')
    Freshness(asOf:datetime, maxLag:timespan) {
    union withsource=TableName Sales, Telemetry
    | extend EventTime = coalesce(OrderTime, Timestamp)
    | summarize LastEvent = max(EventTime) by TableName
    | extend Lag = asOf - LastEvent
    | extend Stale = Lag > maxLag
}

Freshness(datetime(2026-10-20 12:00), 30m)
| order by TableName asc
""")
run(KQL + ' -q -f freshness.kql')
Next in this series

Part 10: Real-World Manager Scenarios

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.