Berserk Docs

The $raw Column

How Berserk resolves column names using $raw in permissive and strict mode

Berserk KQL resolves names differently than Microsoft's Kusto. In Microsoft Kusto, tables always have a fixed schema and unrecognized column names produce errors. Berserk introduces permissive mode and the special $raw column to support semi-structured data common in observability workloads.

What is $raw?

Every table (source table) in Berserk has exactly one column: $raw, a dynamic property bag containing the full original record. When data is ingested (e.g. from OpenTelemetry), the entire JSON object is stored in $raw. See OTEL Field Mapping for exactly how OpenTelemetry data is encoded as JSON.

Permissive vs Strict Mode

Berserk has two query modes that control how unknown field names are resolved:

Permissive Mode (default)

In permissive mode, bare identifiers that don't match a declared column are automatically resolved as property paths into $raw. This is called auto-projection.

default | where level == "INFO"

If level is not a declared column but the table has $raw, this is equivalent to:

default | where $raw.level == "INFO"

The auto-projected field has type dynamic, since its actual type is unknown at query time. The editor shows an informational hint on auto-projected fields to indicate they are being resolved from $raw.

Strict Mode

In strict mode, every column reference must match a declared column in the schema. Unknown fields produce an error:

-- Error: unknown column 'level'
Fruit | where level == "INFO"

This is closer to Microsoft Kusto's behavior. Use strict mode when working with well-defined schemas where typos should be caught early.

Explicit $raw Access

You can always access $raw directly using dotted path syntax:

default | where $raw.level == "INFO"

This works in both permissive and strict mode. Nested paths are supported:

default | where $raw.resource["service.name"] == "api-gateway"

Array indexing is also supported:

default | where $raw.items[0].name == "first"

Type Annotations

Since auto-projected fields are dynamic, arithmetic and comparison operations may need type hints. Use the annotate operator to declare expected types:

default
| annotate response_time:real, status_code:int
| where status_code >= 400
| summarize avg(response_time) by bin(timestamp, 5m)

Without annotation, response_time and status_code stay dynamic. Annotate them rather than reaching for toreal() / tolong(): a cast wraps the column, which stops the planner reasoning about it and disables chunk pruning, so the query reads data it could have skipped. A declaration costs nothing.

Durations and timestamps

Annotate is not optional for temporal fields — it is the only thing that works.

A numeric aggregate over a dynamic field extracts a number from it, and a timespan is not a number, so it extracts to null and the aggregate has nothing to average:

prod | summarize p95 = percentile(duration, 95)
// p95:real = NaN

Casting does not rescue it, and costs you twice. toreal() and tolong() succeed, but hand back the raw 100-nanosecond tick count and throw the type away — you get 49528.8 instead of a duration — and wrapping the column stops the planner pruning chunks by it, so the query scans more than it needs.

Annotate the field instead. Every aggregate over it returns a timespan, and the column stays prunable:

prod
| annotate duration:timespan
| summarize p95 = percentile(duration, 95), avg_d = avg(duration)
// p95:timespan = 00:00:00.0049528, avg_d:timespan = 00:00:00.0702141

The same applies to datetime fields. annotate declares a type without converting anything, which is why it preserves the value where a cast cannot — see nulls and coercion for the extract versus convert distinction.

Comparison with Microsoft Kusto

BehaviorMicrosoft KustoBerserk (Permissive)Berserk (Strict)
Unknown columnErrorAuto-project from $rawError
$raw columnNo equivalentBuilt-in dynamic columnBuilt-in dynamic column
Type of auto-projected fieldsN/AdynamicN/A

On this page