Migrate LogQL, KQL, and SPL Queries to SQL
Moving an operational query to SQL is a data-model migration, not a find-and-replace exercise. LogQL begins with log streams and label selectors, Kusto Query Language uses a tabular pipeline, and Splunk SPL transforms search results through commands. SQL begins with relations and makes selection, grouping, joins, and projection explicit.
The safest migration preserves the question and result contract before changing syntax.
First write down the semantic contract
For every query, record:
- the decision it supports;
- the source and time window;
- the row or event grain;
- parsed fields and their types;
- grouping dimensions;
- numerator and denominator;
- null, duplicate, retry, and late-arrival rules;
- expected columns and sort order.
Run the old and new queries over the same bounded interval. Compare totals first, then group-level results, then representative raw rows. A result that looks visually similar can still use a different denominator.
A shared example event
The translations below assume one typed row per completed API request:
{
"timestamp": "2026-07-28T16:04:00Z",
"event_name": "api_request_completed",
"service": "checkout-api",
"route": "/v1/orders/:id",
"status_code": 503,
"latency_ms": 842,
"request_id": "req_01J...",
"release": "2026.07.28"
}
If the old system stores only a message such as "GET /v1/orders/123 returned 503 in 842ms", first add a parser or change instrumentation. SQL cannot recover a stable route template or reliable numeric type that was never recorded.
Concept map
| Intent | LogQL | KQL | SPL | SQL |
|---|---|---|---|---|
| Choose source | stream selector | table expression | index and source search | FROM table |
| Filter rows | line or label filter | where |
search or where |
WHERE |
| Parse fields | parser expression | parse, extend |
rex, spath, eval |
preferably typed columns before query |
| Select columns | line format | project |
fields or table |
SELECT |
| Aggregate | metric query | summarize |
stats or timechart |
aggregates plus GROUP BY |
| Pipeline | ` | ` stages | ` | ` operators |
| Time bucket | range vector | bin() |
timechart span= |
date_trunc() |
This table maps intent, not exact equivalence. For example, a Loki label participates in stream indexing and cardinality constraints; a SQL column does not automatically have the same storage behavior.
Example 1: filter recent errors
LogQL
{service="checkout-api"} | json | status_code >= 500
KQL
ApiRequestCompleted
| where Timestamp > ago(1h)
| where Service == "checkout-api" and StatusCode >= 500
| project Timestamp, Route, StatusCode, LatencyMs, RequestId
| order by Timestamp desc
SPL
index=production service=checkout-api status_code>=500 earliest=-1h
| table _time route status_code latency_ms request_id
| sort - _time
SQL
SELECT
timestamp_utc,
route,
status_code,
latency_ms,
request_id
FROM api_request_completed
WHERE timestamp_utc >= now() - INTERVAL '1 hour'
AND service = 'checkout-api'
AND status_code >= 500
ORDER BY timestamp_utc DESC
LIMIT 200;
The explicit limit protects an exploratory query from returning an unbounded incident window. It is not part of an aggregate report.
Example 2: calculate error rate by route
Pipeline languages often make the numerator easy to see while the denominator is implied by an earlier stage. Keep both in the SQL result:
SELECT
route,
COUNT(*) AS requests,
SUM(CASE WHEN status_code >= 500 THEN 1 ELSE 0 END) AS errors,
ROUND(
100.0 * SUM(CASE WHEN status_code >= 500 THEN 1 ELSE 0 END)
/ NULLIF(COUNT(*), 0),
2
) AS error_rate_pct
FROM api_request_completed
WHERE timestamp_utc >= now() - INTERVAL '24 hours'
AND service = 'checkout-api'
GROUP BY route
ORDER BY error_rate_pct DESC, requests DESC;
Do not translate a LogQL count_over_time into COUNT(*) until you confirm that one parsed log entry corresponds to one SQL row and that retries or multi-line messages do not change the grain.
Example 3: build a time chart
KQL summarize ... by bin(Timestamp, 5m), SPL timechart span=5m, and LogQL range aggregations all express a time series. In DataFusion SQL, a portable first step is an hour bucket:
SELECT
date_trunc('hour', timestamp_utc) AS hour,
release,
COUNT(*) AS requests,
approx_percentile_cont(latency_ms, 0.95) AS p95_latency_ms
FROM api_request_completed
WHERE timestamp_utc >= now() - INTERVAL '7 days'
GROUP BY date_trunc('hour', timestamp_utc), release
ORDER BY hour ASC, release ASC;
Choose a bucket supported by the engine and appropriate for the event volume. Exclude or annotate the newest bucket when it is incomplete.
Example 4: replace a pipeline with common table expressions
Pipelines are readable because each stage transforms the prior table. SQL common table expressions can preserve that shape:
WITH recent_requests AS (
SELECT *
FROM api_request_completed
WHERE timestamp_utc >= now() - INTERVAL '24 hours'
AND service = 'checkout-api'
),
route_summary AS (
SELECT
route,
COUNT(*) AS requests,
SUM(CASE WHEN status_code >= 500 THEN 1 ELSE 0 END) AS errors
FROM recent_requests
GROUP BY route
)
SELECT
route,
requests,
errors,
ROUND(100.0 * errors / NULLIF(requests, 0), 2) AS error_rate_pct
FROM route_summary
WHERE requests >= 100
ORDER BY error_rate_pct DESC;
Name stages for their meaning rather than step1 or filtered. The resulting query remains easy to review and test.
Parsing is the migration risk
Existing queries may depend on regular expressions, JSON extraction, automatic field discovery, or vendor-specific search-time parsing. Inventory each derived field:
| Derived field | New event field | Type | Fallback during cutover |
|---|---|---|---|
| request method | method |
string category | parse old message |
| route template | route |
string category | map raw path to template |
| response code | status_code |
integer | cast parsed value |
| duration | latency_ms |
integer | normalize seconds or microseconds |
| release | release |
string category | enrich from deployment metadata |
Run dual collection until the typed field is present and correct across producers. Do not remove the old parser because a single happy-path service matched.
Vendor-specific features that need redesign
Some constructs should not be forced into generic SQL:
- LogQL stream labels and unwrap operations combine storage selection with parsing.
- KQL has rich dynamic-value, time-series, and anomaly functions.
- SPL has search-time knowledge objects, transactions, and command-specific behavior.
- Each system applies different default timezones, null semantics, limits, and approximate aggregation algorithms.
Preserve a vendor query when it is the best tool for an incident or data type. The goal is a dependable SQL event model for shared questions, not language purity.
Refer to the current first-party references for LogQL query examples, Kusto query operators, and the Splunk Search Reference.
Validation and cutover
For each migrated query:
- freeze a representative interval;
- compare source row counts;
- compare distinct operation identifiers;
- compare null and parse-failure counts;
- compare every output group, not only the grand total;
- explain accepted differences;
- run both dashboards through at least one normal traffic cycle;
- keep a rollback link to the old query until users accept the new result.
Use Troubleshooting SQL Queries for engine and type problems, Migrate Logs to Structured Events for instrumentation rollout, and the SQL cookbook for complete contracts and visual outputs.