Common mistakes and known-correct patterns for CrowdStrike LogScale CQL. Organized by the markdown file where each topic best belongs.
limit() does not exist as a standalone pipeline function. Use head(n) to return the first n rows, or use limit= as a parameter on groupBy(), sort(), and table().
Additionally, chaining sort() then head() does not guarantee the sorted order is preserved — head() may return rows in arbitrary order. Always use limit= directly on sort() to atomically sort and cap in one step. Also note head() takes a positional integer, not limit= as a named parameter.
// WRONG — limit() is not a function
| sort(field, order=desc)
| limit(10)
// WRONG — head(limit=10) is not valid syntax; head() takes a positional arg
| sort(field, order=desc)
| head(limit=10)
// WRONG — sort order is not guaranteed to be preserved by downstream head()
| sort(field, order=desc)
| head(10)
// CORRECT — sort and cap atomically; order is guaranteed
| sort(field, order=desc, limit=10)
// CORRECT — limit= as a parameter on groupBy/sort/table
| groupBy([host], function=count(as=n), limit=max)
| sort(n, order=desc, limit=10)
| table([host, n], limit=max)table() caps at 200 rows by default. Always add limit=max to remove the cap. select() is the canonical cap-free alternative — it does not aggregate and has no row limit.
// CORRECT
| table([fields], limit=max)
| sort(field, order=desc, limit=max)The standard pipeline order (Time → Tags → Filter → Negative Filter → Regex → Functions → Aggregate → Rename → Join → View) has two critical exceptions:
$falcon/helper:enrich()andmatch()MUST run before any filter orgroupBythat depends on the enriched/joined field — even if that means running before step 7. Enrichment order is dependency-driven, not position-driven.correlate()must appear before anygroupBy(),table(), ortimechart().- Aggregate functions cannot be used inside
correlate()query blocks.
eval() cannot contain function calls directly. Assign any function output to a field first, then use that field in eval().
// WRONG
| eval(ageDays = (now() - lastTS) / 86400000)
// CORRECT
| nowMS := now()
| eval(ageDays = (nowMS - lastTS) / 86400000)test()is required in pipeline filter steps for field-to-field comparisons. Direct!=compares against a literal string, not the field value.test()is required insidecase{}for numeric comparisons (<=,>=,>,<).test()is NOT supported insidecase{}for string field comparisons — use direct equality.- Function calls are NOT supported in
case{}filter expressions.
// WRONG — direct field-to-field comparison
| fieldA != fieldB
// CORRECT — field-to-field comparison
| test(fieldA != fieldB)
// WRONG — test() wrapping string comparison inside case{}
| case { test(fieldA = "value") AND test(fieldB = "value") | result := "x"; }
// CORRECT — direct equality inside case{}
| case { fieldA = "value" AND fieldB = "value" | result := "x"; }
// CORRECT — numeric comparison inside case{} requires test()
| case {
test(ageDays <= 7) | bucket := "Active";
test(ageDays <= 30) | bucket := "Recent";
* | bucket := "Stale";
}SQL-style in() is not supported. Use the explicit function form.
// WRONG
| fieldName in (value1, value2, value3)
// CORRECT
| in(field="fieldName", values=[value1, value2, value3])round() operates on a field, not an expression. Evaluate first, then round the resulting field.
// WRONG
| round((a / b) * 100)
// CORRECT
| eval(pct = (a / b) * 100)
| round(pct)join() subqueries are hard-capped at 200,000 rows. Subqueries that exceed this silently truncate, producing incomplete results with no error. join() also does not work in multi-cluster views.
Prefer defineTable() + match() for any join that may return large result sets or runs across clusters:
// AVOID for large datasets or multi-cluster views
| join(query={...}, field=aid, include=[hostname])
// PREFER — no row cap, multi-cluster compatible
defineTable(name="hosts", query={...}, include=[aid, hostname])
| #repo="base_sensor" ...
| match(table="hosts", field=aid, column=aid, include=[hostname], strict=false)Why defineTable + match is faster:
- Subquery and primary query execute separately and in parallel;
join()is sequential - Ad-hoc tables are compressed in memory for live queries
strict=falsegives left-join semantics (keep unmatched rows);strict=true(default) gives inner join- Mid-query table contents are visible in the UI for validation before the final match runs
Use join() only when you need a right join or when the subquery is guaranteed small (well under 200k rows).
readFile() operates independently of the event stream. It cannot be combined with tag-filtered event queries in a single OR expression. Use union() for separate pipelines instead.
// WRONG
readFile("lookup.csv") OR #repo = "base_sensor" ...
// CORRECT — use union() to combine separate pipelinesmax() is a numeric aggregation — unreliable on string fields. Assign a numeric score pre-groupBy, aggregate on the integer, then map back to a readable label post-groupBy.
// WRONG
| groupBy([field], function=[max(stringField, as=worstValue)])
// CORRECT
| case {
profile = "Critical" | score := 4;
profile = "High" | score := 3;
* | score := 0;
}
| groupBy([field], function=[max(score, as=worstScore)], limit=max)
| case {
worstScore = 4 | worstProfile := "Critical";
worstScore = 3 | worstProfile := "High";
* | worstProfile := "Unknown";
}Over-specified groupBy keys produce one row per unique combination. count(distinct) will always return 1 if the counted field is in the key. Only include fields that define the grouping dimension in the key — fields that should be aggregated across must NOT be in the groupBy key.
Regex on a field simultaneously filters and extracts. Any event where the field does not match is silently dropped. Use case{} with a wildcard fallback to handle all formats without data loss.
// WRONG — silently drops non-matching events
| field = /^pattern\/(?P<capture>[^\/]+)/
// CORRECT — handles all formats
| case {
field = /^pattern\/(?P<capture>[^\/]+)/ | format := "TypeA";
* | capture := field | format := "TypeB";
}if() does not support regex conditions. Use case{} for any regex-based conditional assignment.
// WRONG
| if(field = /regex/, then="x", else="y")
// CORRECT
| case {
field = /regex/ | newfield := "x";
* | newfield := "y";
}wildcard() is the correct mechanism for user-input dashboard parameters. Supports glob patterns (*value*), case-insensitive when ignoreCase=true. Default value of * returns all results without filtering.
// WRONG
| field = ?Parameter
// CORRECT
| wildcard(field=fieldName, pattern=?Parameter, ignoreCase=true)min() and max() on @timestamp return epoch milliseconds — always convert to human-readable format before display. findTimestamp() is the reverse: it parses date strings into epoch ms.
// Epoch ms → human readable
| formatTime(format="%Y-%m-%d %H:%M:%S", as=lastSeen,
field=lastAuthTS, locale=en_US, timezone=Z)
// Date string → epoch ms
| lastActive := findTimestamp(field=some.date.field)
// Recency calculation — nowMS assigned first, eval() uses field not function
| nowMS := now()
| eval(ageDays = (nowMS - lastAuthTS) / 86400000)
| case {
test(ageDays <= 7) | recencyBucket := "1 · Active (≤ 7d)";
test(ageDays <= 30) | recencyBucket := "2 · Recent (≤ 30d)";
test(ageDays <= 90) | recencyBucket := "3 · Moderate (≤ 90d)";
test(ageDays <= 180) | recencyBucket := "4 · Aging (≤ 180d)";
* | recencyBucket := "5 · Stale (> 180d)";
}The default collect() separator is a newline — unreadable in table cells. Always specify separator= and limit= explicitly.
// WRONG
| collect([field])
// CORRECT
| collect([field], separator=", ", limit=200)Parentheses and special characters in format() output break ?Parameter substitution on dashboards. Use underscore _ as separator when the value will be used as a dashboard parameter.
// WRONG — breaks parameter substitution
| format("%s(%s)", field=[fieldA, fieldB])
// CORRECT
| format("%s_%s", field=[fieldA, fieldB])coalesce() assigns the first non-null value from a prioritized list to a new field. Use when the same logical value lives in different field names across sources. Essential for building unified parameter dropdown values across repos.
| coalesce([fieldA, fieldB, fieldC], as=UnifiedField)- Must appear before any
groupBy(),table(), ortimechart(). - Aggregate functions cannot be used inside
correlate()query blocks. !=is not a supported link operator insidecorrelate().- Use
<=>for field-level pairing when field names differ between queries. - Use
globalConstraints=[]when ALL queries share the same field name.
| correlate(
QueryA: {
#event_simpleName = EventTypeA
| field = value
} include: [field1, field2],
QueryB: {
#event_simpleName = EventTypeB
| field = value
} include: [field1, field2],
globalConstraints=[sharedField],
sequence=true,
within=5m,
includeMatchesOnceOnly=true
)match() is case-sensitive. A case mismatch produces zero matches with no error. Normalize case on both sides before joining, or use ignoreCase=true. This applies to all join fields — GUIDs, hostnames, usernames, domain names.
// CORRECT — Option A: normalize before match
| lower(field=fieldA, as=fieldA)
| match(file="lookup.csv", field=fieldA, column="csv_col", strict=false)
// CORRECT — Option B: ignoreCase on match
| match(file="lookup.csv", field=fieldA, column="csv_col",
ignoreCase=true, strict=false)The =~match() shorthand is only valid when the telemetry field name exactly matches the CSV column name. Use explicit field= and column= when names differ.
// SHORTHAND — only when names match exactly
| aid=~match(file="aid_master_main.csv", column=[aid])
// EXPLICIT — when names differ
| match(file="lookup.csv", field=telemetryField, column="csv_column",
include=[...], strict=false)Enrich MUST run before any filter or groupBy on the enriched field. Filtering on an unenriched field produces zero results with no error.
// WRONG
| field = "EnrichedValue"
| $falcon/helper:enrich(field=field)
// CORRECT
| $falcon/helper:enrich(field=field)
| field = "EnrichedValue"When an enriched or joined field is needed as a groupBy key or filter, enrichment must run before aggregation — not after.
// WRONG
| groupBy([enrichedField, ...], ...)
| $falcon/helper:enrich(field=X)
// CORRECT
| $falcon/helper:enrich(field=X)
| groupBy([enrichedField, ...], ...)- Parser schema: https://schemas.humio.com/parser/v0.3.0
- Parsing standard (PASTA): https://library.humio.com/logscale-parsing-standard/pasta.html
- ECS field reference: https://www.elastic.co/docs/reference/ecs/ecs-category-field-values-reference
bucket() must run as a separate pipeline step before groupBy. It creates the _bucket field which is then referenced in groupBy. It cannot be nested inside the groupBy field list.
// WRONG
| groupBy([field, bucket(span=5m)], ...)
// CORRECT
| bucket(span=5m)
| groupBy([field, _bucket], ...)Insert fieldset() at any point in the pipeline to see what fields are available at that step. Fields present in raw events may not survive upstream filters — always validate with fieldset() before referencing a field downstream.
| fieldset()Parsers make heavy use of both conditional forms. if() does not support regex conditions — use case{} for any regex-based conditional assignment.
// WRONG
| if(field = /regex/, then="x", else="y")
// CORRECT
| case {
field = /regex/ | newfield := "x";
* | newfield := "y";
}