Loading

LINQ to ES|QL translation

How LINQ operators map to ES|QL commands. Elastic.Esql uses expression visitors to translate LINQ method chains into the ES|QL pipe-delimited command syntax.

Every query starts with FROM. The source index pattern is specified using the .From() extension method:

var query = new EsqlQueryable<LogEntry>()
    .From("logs-*")
    .Where(l => l.Level == "ERROR")
    .ToString();
		
FROM logs-*
| WHERE log.level == "ERROR"
		

The .From() method accepts any index pattern string:

.From("logs-*")
.From("products")
.From("orders-2025.*")
		
  1. FROM logs-*
  2. FROM products
  3. FROM orders-2025.*

If .From() is not called, the type name is used as the default index pattern with camelCase convention (e.g., LogEntry becomes FROM logEntry).

Field names are resolved from your C# type using System.Text.Json conventions:

  • [JsonPropertyName("@timestamp")] on a property produces @timestamp in the query
  • Properties without [JsonPropertyName] are resolved using the configured JsonNamingPolicy (defaults to camelCase)
public class LogEntry
{
    [JsonPropertyName("@timestamp")]
    public DateTime Timestamp { get; set; }

    [JsonPropertyName("log.level")]
    public string Level { get; set; }

    public string Message { get; set; }
    public int StatusCode { get; set; }
}
		
  1. → @timestamp
  2. → log.level
  3. → message
  4. → statusCode

.Where() translates to the ES|QL WHERE command. Multiple .Where() calls produce multiple WHERE commands:

query
    .Where(l => l.StatusCode >= 500)
    .Where(l => l.Level == "ERROR")
		
FROM logs-*
| WHERE statusCode >= 500
| WHERE log.level == "ERROR"
		
.Where(l => l.Tag == null)
.Where(l => l.Tag != null)
		
  1. WHERE tag IS NULL
  2. WHERE tag IS NOT NULL
.Where(l => l.Level == "ERROR" && l.Duration > 1000)
// WHERE (log.level == "ERROR" AND duration > 1000)

.Where(l => l.Level == "ERROR" || l.Level == "FATAL")
// WHERE (log.level == "ERROR" OR log.level == "FATAL")

.Where(l => !(l.StatusCode >= 500))
// WHERE NOT (statusCode >= 500)
		
var levels = new[] { "ERROR", "FATAL", "CRITICAL" };
query.Where(l => levels.Contains(l.Level))
// WHERE log.level IN ("ERROR", "FATAL", "CRITICAL")
		
.Where(l => l.IsError)
.Where(l => !l.IsError)
		
  1. WHERE isError
  2. WHERE NOT isError
.Where(l => l.Message.Contains("timeout"))    // WHERE message LIKE "*timeout*"
.Where(l => l.Host.StartsWith("prod-"))       // WHERE host LIKE "prod-*"
.Where(l => l.Path.EndsWith(".json"))          // WHERE path LIKE "*.json"
.Where(l => string.IsNullOrEmpty(l.Tag))       // WHERE (tag IS NULL OR tag == "")
		

Captured C# variables are inlined by default:

var minStatus = 400;
query.Where(l => l.StatusCode >= minStatus)
// WHERE statusCode >= 400
		

Use .ToEsqlString(inlineParameters: false) to extract them as named ?param placeholders:

var esql = query
    .Where(l => l.StatusCode >= minStatus)
    .ToEsqlString(inlineParameters: false);
// WHERE statusCode >= ?minStatus
		

The parameter values are retrievable via .GetParameters() for passing to the ES|QL API.

.GroupBy() followed by .Select() translates to the ES|QL STATS...BY command:

query
    .GroupBy(l => l.Level)
    .Select(g => new
    {
        Level = g.Key,
        Count = g.Count(),
        AvgDuration = g.Average(l => l.Duration)
    })
		
FROM logs-*
| STATS count = COUNT(*), avgDuration = AVG(duration) BY level = log.level
		
query
    .GroupBy(l => new { l.Level, l.Host })
    .Select(g => new
    {
        Level = g.Key.Level,
        Host = g.Key.Host,
        Count = g.Count()
    })
		
FROM logs-*
| STATS count = COUNT(*) BY level = log.level, host
		

Use EsqlFunctions.Bucket(), EsqlFunctions.TBucket(), and EsqlFunctions.Categorize() in group keys:

query
    .GroupBy(l => EsqlFunctions.Bucket(l.Duration, 10))
    .Select(g => new { Bucket = g.Key, Count = g.Count() })
// STATS count = COUNT(*) BY bucket = BUCKET(duration, 10)

query
    .GroupBy(l => EsqlFunctions.TBucket(l.Timestamp, "1 hour"))
    .Select(g => new { Hour = g.Key, Count = g.Count() })
// STATS count = COUNT(*) BY hour = TBUCKET(@timestamp, "1 hour")
		

Aggregation methods called directly on the queryable produce STATS without BY:

query.Where(l => l.Level == "ERROR").Count()
// FROM logs-* | WHERE log.level == "ERROR" | STATS count = COUNT(*)

query.Sum(l => l.Duration)
// FROM logs-* | STATS sum = SUM(duration)
		

Beyond standard LINQ aggregations (Count, Sum, Average, Min, Max), ES|QL-specific aggregations are available through EsqlFunctions:

.Select(g => new
{
    P99 = EsqlFunctions.Percentile(g, l => l.Duration, 99),
    Med = EsqlFunctions.Median(g, l => l.Duration),
    Distinct = EsqlFunctions.CountDistinct(g, l => l.Host),
    Dev = EsqlFunctions.StdDev(g, l => l.Duration),
    Vals = EsqlFunctions.Values(g, l => l.Host)
})
		

See the functions reference for the complete list of aggregation functions.

.OrderBy() and .OrderByDescending() translate to the ES|QL SORT command:

query
    .OrderBy(l => l.Level)
    .ThenByDescending(l => l.Timestamp)
		
FROM logs-*
| SORT log.level, @timestamp DESC
		

.Take() translates to the ES|QL LIMIT command:

query.Take(100)
// | LIMIT 100
		

.First() and .FirstOrDefault() produce LIMIT 1. .Single() and .SingleOrDefault() produce LIMIT 2 (to validate exactly one result).

.Select() translates to KEEP for simple field selections and EVAL for computed fields:

// Simple projection → KEEP
query.Select(l => new { l.Message, l.Timestamp })
// | KEEP message, @timestamp

// Computed fields → EVAL + KEEP
query.Select(l => new { l.Message, Secs = l.Duration / 1000 })
// | EVAL secs = (duration / 1000)
// | KEEP message, secs

// Function calls → EVAL
query.Select(l => new { Upper = l.Message.ToUpper(), Hour = l.Timestamp.Hour })
// | EVAL upper = TO_UPPER(message), hour = DATE_EXTRACT("hour", @timestamp)
		

The ternary operator translates to CASE:

query.Select(l => new { Status = l.StatusCode >= 500 ? "error" : "ok" })
// | EVAL status = CASE WHEN statusCode >= 500 THEN "error" ELSE "ok" END
		

When a property name in the anonymous type differs from the source field name, a RENAME command is generated:

query.Select(l => new { Msg = l.Message })
// | RENAME message AS msg
// | KEEP msg
		

Nested anonymous projections are flattened to dotted field names:

query.Select(l => new { A = new { B = l.Message } })
// | RENAME message AS a.b
// | KEEP a.b
		

Consecutive Select calls can still be merged through nested member access:

query
    .Select(l => new { A = new { B = l.Message } })
    .Select(x => x.A.B)
// | KEEP message
		

In addition to .Select(), explicit .Keep() and .Drop() extension methods are available for fine-grained control:

query.Keep("message", "statusCode")
// | KEEP message, statusCode
		
query.Keep(l => l.Message, l => l.StatusCode)
// | KEEP message, statusCode

query.Keep(l => l.Host)
// | KEEP host.*
		

Lambda selectors resolve field names from [JsonPropertyName] attributes automatically. Selecting a complex object member expands to a wildcard keep (field.*) so ES|QL returns flattened sub-fields.

query.Keep(l => new { l.Message, l.StatusCode })
// | KEEP message, statusCode

query.Keep(l => new { Msg = l.Message })
// | RENAME message AS msg
// | KEEP msg

query.Keep(l => new { l.Host })
// | KEEP host.*

// Object aliases are not supported (ES|QL has no equivalent for renaming field.*)
query.Keep(l => new { Node = l.Host })
		
  1. throws NotSupportedException
query.Drop("duration", "host")
// | DROP duration, host
		
query.Drop(l => l.Duration, l => l.Host)
// | DROP duration, host

query.Drop(l => l.Host)
// | DROP host.*
		

ES|QL's LOOKUP JOIN command correlates data from a lookup index. Multiple API styles are supported.

The most explicit form takes a string index name and key selectors:

query
    .From("employees")
    .LookupJoin<LogEntry, LanguageLookup, int, object>(
        "languages_lookup",
        outer => outer.StatusCode,
        inner => inner.LanguageCode,
        (outer, inner) => new { outer.Message, inner!.LanguageName }
    )
		
FROM employees
| LOOKUP JOIN languages_lookup ON statusCode == languageCode
| KEEP message, languageName
		

When outer and inner key selectors reference the same field name, a simple ON clause is generated:

outer => outer.ClientIp,
inner => inner.ClientIp,
// ON clientIp (instead of ON clientIp == clientIp)
		

Use an expression-based ON condition for more complex join logic:

query
    .From("employees")
    .LookupJoin<LogEntry, LanguageLookup, object>(
        "languages_lookup",
        (outer, inner) => outer.StatusCode == inner.LanguageCode,
        (outer, inner) => new { outer.Message, inner!.LanguageName }
    )
		

Use LeftJoin when the inner source is another EsqlQueryable with .From():

var lookup = new EsqlQueryable<LanguageLookup>().From("languages_lookup");

query
    .From("employees")
    .LeftJoin(
        lookup,
        outer => outer.StatusCode,
        inner => inner.LanguageCode,
        (outer, inner) => new { outer.Message, inner!.LanguageName }
    )
		
FROM employees
| LOOKUP JOIN languages_lookup ON statusCode == languageCode
| KEEP message, languageName
		

The standard Queryable.Join is also supported. It translates to LOOKUP JOIN followed by WHERE key IS NOT NULL to enforce inner join semantics:

query
    .From("firewall_logs")
    .Join(
        lookup,
        outer => outer.ClientIp,
        inner => inner.ClientIp,
        (outer, inner) => new { outer.Message, inner.ThreatLevel }
    )
		
FROM firewall_logs
| LOOKUP JOIN threat_list ON clientIp
| WHERE clientIp IS NOT NULL
| KEEP message, threatLevel
		

The standard LINQ query syntax for left outer joins works naturally:

var lookup = new EsqlQueryable<LanguageLookup>().From("languages_lookup");

var esql = (
    from outer in query.From("employees")
    join inner in lookup on outer.StatusCode equals inner.LanguageCode into ps
    from inner in ps.DefaultIfEmpty()
    select new { outer.Message, inner!.LanguageName }
).ToString();
		
FROM employees
| LOOKUP JOIN languages_lookup ON statusCode == languageCode
| KEEP message, languageName
		

The result selector supports computed fields, renames, and null guards:

// Computed field → EVAL
(outer, inner) => new { Msg = outer.Message, Lang = inner!.LanguageName.ToUpperInvariant() }
// | RENAME message AS msg
// | EVAL lang = TO_UPPER(languageName)
// | KEEP msg, lang

// Null guard → unwraps to simple field access
(outer, inner) => new { LanguageName = inner == null ? null : inner.LanguageName }
// | KEEP languageName
		
query
    .From("system_metrics")
    .LookupJoin<LogEntry, ThreatListEntry, string?, LogEntry>(
        "host_inventory", outer => outer.ClientIp, inner => inner.ClientIp, (o, i) => o)
    .LookupJoin<LogEntry, ThreatListEntry, string?, LogEntry>(
        "ownerships", outer => outer.ServerName, inner => inner.ClientIp, (o, i) => o)
		
FROM system_metrics
| LOOKUP JOIN host_inventory ON clientIp
| LOOKUP JOIN ownerships ON serverName == clientIp
		

The .Row() extension method produces a ROW source command with literal values:

query
    .Row(() => new { a = 1, b = "hello" })
    .ToString()
		
ROW a = 1, b = "hello"
		

This is primarily used with COMPLETION for standalone LLM prompts without querying an index.

.Completion() translates to the ES|QL COMPLETION command. It sends a field to a configured inference endpoint and returns the result as a new column. See the COMPLETION docs for full details.

query
    .Completion(l => l.Message, InferenceEndpoints.OpenAi.Gpt41, column: "analysis")
		
FROM logs-*
| COMPLETION analysis = message WITH { "inference_id" : ".openai-gpt-4.1-completion" }
		

The lambda overload resolves field names from your type. A string overload is also available for raw field names:

query.Completion("message", "my-custom-endpoint", column: "result")
		

Access sub-fields of multi-field mappings using .MultiField():

.Where(l => l.Message.MultiField("keyword") == "exact match")
// WHERE message.keyword == "exact match"
		

Captured C# variables can be parameterized instead of inlined:

var minStatus = 400;
var level = "ERROR";

var esql = query
    .Where(l => l.StatusCode >= minStatus && l.Level == level)
    .ToEsqlString(inlineParameters: false);
		
FROM logs-*
| WHERE (statusCode >= ?minStatus AND log.level == ?level)
		

Parameters are extracted separately via .GetParameters() for passing to the ES|QL API. When inlineParameters is true (the default), values are embedded directly in the query string.

LINQ method Reason
.Skip() ES|QL does not support offset-based pagination
.Distinct() Use .GroupBy() instead
Nested subqueries ES|QL does not support subqueries