Loading

Using the Elastic.Clients.Esql package

Elastic.Clients.Esql adds query execution to Elastic.Esql. It connects LINQ-based query translation to a real Elasticsearch cluster via Elastic.Transport, handling HTTP communication, authentication, and result materialization.

dotnet add package Elastic.Clients.Esql
		

This pulls in Elastic.Esql and Elastic.Transport automatically.

using var client = new EsqlClient(new Uri("https://my-cluster:9200"));
		
var transport = new DistributedTransport(
    new TransportConfiguration(
        new Uri("https://my-cluster:9200"),
        new ApiKey("your-api-key")
    )
);
using var client = new EsqlClient(new EsqlClientSettings(transport));
		
var pool = new StaticNodePool(new[]
{
    new Uri("https://node1:9200"),
    new Uri("https://node2:9200")
});
using var client = new EsqlClient(new EsqlClientSettings(pool));
		
var settings = new EsqlClientSettings(transport)
{
    Defaults = new EsqlQueryDefaults
    {
        TimeZone = "UTC",
        Locale = "en-US"
    }
};
using var client = new EsqlClient(settings);
		

For Native AOT, supply a source-generated JsonSerializerContext to control how result types are materialized:

[JsonSerializable(typeof(LogEntry))]
[JsonSerializable(typeof(Product))]
public partial class MyJsonContext : JsonSerializerContext;

var settings = new EsqlClientSettings(transport)
{
    JsonSerializerContext = MyJsonContext.Default
};
using var client = new EsqlClient(settings);
		

When JsonSerializerContext is set, it takes precedence over JsonSerializerOptions. You can also set JsonSerializerOptions directly for non-AOT scenarios:

var settings = new EsqlClientSettings(transport)
{
    JsonSerializerOptions = new JsonSerializerOptions
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase
    }
};
		

If neither JsonSerializerContext nor JsonSerializerOptions is provided, EsqlClient defaults to camelCase naming.

var results = await client.CreateQuery<LogEntry>()
    .From("logs-*")
    .Where(l => l.Level == "ERROR" && l.Duration > 500)
    .OrderByDescending(l => l.Timestamp)
    .Take(50)
    .ToListAsync();
		
var results = await (
    from l in client.CreateQuery<LogEntry>().From("logs-*")
    where l.Level == "ERROR"
    orderby l.Timestamp descending
    select new { l.Message, l.Duration }
).ToListAsync();
		
await foreach (var entry in client.QueryAsync<LogEntry>(q =>
    q.From("logs-*")
     .Where(l => l.Level == "ERROR")
     .OrderByDescending(l => l.Timestamp)
     .Take(10)))
{
    Console.WriteLine(entry.Message);
}
		
var results = client.Query<LogEntry>(q =>
    q.From("logs-*")
     .Where(l => l.Level == "ERROR")
     .Take(10));
		
await foreach (var item in client.QueryAsync<LogEntry, dynamic>(q =>
    q.From("logs-*")
     .Where(l => l.Level == "ERROR")
     .Select(l => new { l.Message, l.Duration })))
{
    Console.WriteLine(item);
}
		

Use RawEsql() to append expert-level ES|QL fragments directly in a query pipeline:

var results = client.Query<LogEntry>(q => q
    .From("logs-*")
    .RawEsql("WHERE statusCode >= 500")
    .RawEsql("| LIMIT 10"));
		

You can also switch the downstream materialization type:

var rows = client.Query<LogEntry, LogProjection>(q => q
    .From("logs-*")
    .RawEsql<LogEntry, LogProjection>("KEEP message, statusCode"));
		

For Native AOT, include the target type (LogProjection in this example) in your source-generated JsonSerializerContext.

Use .WithOptions() to attach options to individual queries.

var results = await client.CreateQuery<LogEntry>()
    .WithOptions(new EsqlQueryOptions { TimeZone = "America/New_York", Locale = "en-US" })
    .From("logs-*")
    .Where(l => l.Level == "ERROR")
    .Take(50)
    .ToListAsync();
		

It works with all execution styles -- lambda, query syntax, and streaming:

await foreach (var entry in client.QueryAsync<LogEntry>(q => q
     .WithOptions(new EsqlQueryOptions { TimeZone = "UTC" })
     .From("logs-*")
     .Where(l => l.Level == "ERROR")))
{
    Console.WriteLine(entry.Message);
}
		

For async queries, EsqlAsyncQueryOptions controls the async submission behavior. Can be used together with .WithOptions():

await using var asyncQuery = await client.CreateQuery<LogEntry>()
    .WithOptions(new EsqlQueryOptions { TimeZone = "UTC" })
    .From("logs-*")
    .Where(l => l.Level == "ERROR")
    .ToAsyncQueryAsync(new EsqlAsyncQueryOptions
    {
        WaitForCompletionTimeout = TimeSpan.FromSeconds(5),
        KeepAlive = TimeSpan.FromMinutes(10)
    });

var results = await asyncQuery.ToListAsync();
		

Or via the EsqlClient convenience methods:

await using var asyncQuery = await client.SubmitAsyncQueryAsync<LogEntry>(
    q => q.WithOptions(new EsqlQueryOptions { TimeZone = "UTC" })
          .From("logs-*")
          .Where(l => l.Level == "ERROR"),
    new EsqlAsyncQueryOptions { KeepOnCompletion = true }
);
		
Option Type Description
RequestConfiguration IRequestConfiguration? Per-request transport overrides
AllowPartialResults bool? Allow partial results when shards are unavailable
DropNullColumns bool? Omit columns where every value is null from the response
TimeZone string? Timezone for date operations (e.g., "UTC", "America/New_York")
Locale string? Locale for formatting (e.g., "en-US")

These options are specific to Elastic.Clients.Esql. Other downstream implementations may define their own WithOptions extensions with different option types.

Use RequestConfiguration to control transport behavior per query -- for example, custom timeouts, authentication, or headers:

var results = await client.CreateQuery<LogEntry>()
    .WithOptions(new EsqlQueryOptions
    {
        RequestConfiguration = new RequestConfiguration
        {
            RequestTimeout = TimeSpan.FromSeconds(120),
            Authentication = new BasicAuthentication("user", "pass"),
            Headers = new NameValueCollection { { "X-Custom-Header", "value" } }
        }
    })
    .From("logs-*")
    .ToListAsync();
		

The RequestConfiguration is forwarded to all transport calls -- including poll and delete operations for async queries.

var count = await client.CreateQuery<LogEntry>()
    .From("logs-*")
    .Where(l => l.Level == "ERROR")
    .CountAsync();

var hasErrors = await client.CreateQuery<LogEntry>()
    .From("logs-*")
    .Where(l => l.Level == "ERROR")
    .AnyAsync();

var first = await client.CreateQuery<LogEntry>()
    .From("logs-*")
    .Where(l => l.Level == "ERROR")
    .FirstOrDefaultAsync();

var single = await client.CreateQuery<LogEntry>()
    .From("logs-*")
    .Where(l => l.Level == "ERROR")
    .Take(1)
    .SingleAsync();
		

All async query methods return IAsyncEnumerable<T>, enabling memory-efficient streaming of large result sets:

await foreach (var entry in client.QueryAsync<LogEntry>(q =>
    q.From("logs-*").Take(10000)))
{
    ProcessEntry(entry);
}
		

You can also get an IAsyncEnumerable<T> from any queryable:

var query = client.CreateQuery<LogEntry>().From("logs-*").Take(100);

await foreach (var entry in query.AsAsyncEnumerable())
{
    ProcessEntry(entry);
}
		

Long-running queries can be submitted asynchronously. The cluster returns a query ID that you can poll for completion. The EsqlAsyncQuery<T> type manages the lifecycle and auto-deletes the query from the cluster on dispose.

await using var asyncQuery = await client.SubmitAsyncQueryAsync<LogEntry>(
    q => q.From("logs-*").Where(l => l.Level == "ERROR"),
    new EsqlAsyncQueryOptions
    {
        WaitForCompletionTimeout = TimeSpan.FromSeconds(5),
        KeepAlive = TimeSpan.FromMinutes(10)
    }
);

// Wait for completion if still running, then get results
var results = await asyncQuery.ToListAsync();
		
await using var asyncQuery = await client.CreateQuery<LogEntry>()
    .From("logs-*")
    .Where(l => l.Level == "ERROR")
    .ToAsyncQueryAsync(new EsqlAsyncQueryOptions
    {
        WaitForCompletionTimeout = TimeSpan.FromSeconds(1),
        KeepOnCompletion = true
    });

if (asyncQuery.IsRunning)
{
    Console.WriteLine($"Query {asyncQuery.QueryId} still running...");
    await asyncQuery.WaitForCompletionAsync();
}

await foreach (var entry in asyncQuery.AsAsyncEnumerable())
{
    Console.WriteLine(entry.Message);
}
		
using var asyncQuery = client.SubmitAsyncQuery<LogEntry>(
    q => q.From("logs-*").Where(l => l.Level == "ERROR"));

asyncQuery.WaitForCompletion();
var results = asyncQuery.ToList();
		
Option Default Description
WaitForCompletionTimeout 1s How long to wait before returning an async query ID
KeepAlive 5d How long to keep results on the cluster
KeepOnCompletion false Whether to keep results even if completed within the timeout

The polling interval for WaitForCompletion / WaitForCompletionAsync can be set via the pollInterval parameter (default: 100ms):

await asyncQuery.WaitForCompletionAsync(pollInterval: TimeSpan.FromMilliseconds(500));
		

Use ROW + COMPLETION in the LINQ pipeline for standalone prompts:

var results = await client.CreateQuery<CompletionResult>()
    .Row(() => new { prompt = "Summarize the benefits of Elasticsearch" })
    .Completion("prompt", InferenceEndpoints.OpenAi.Gpt41, column: "answer")
    .ToListAsync();
		

See the COMPLETION docs for pipeline patterns and well-known endpoint IDs.

Call .ToString() or .ToEsqlString() on any query to see the generated ES|QL without executing it:

var query = client.CreateQuery<Product>()
    .From("products")
    .Where(p => p.Price > 100)
    .OrderBy(p => p.Name);

Console.WriteLine(query.ToString());
// FROM products
// | WHERE price > 100
// | SORT name
		

Use .ToEsqlString(inlineParameters: false) to see named parameter placeholders, and .GetParameters() to extract the parameter values:

var minPrice = 100;
var query = client.CreateQuery<Product>()
    .From("products")
    .Where(p => p.Price > minPrice);

Console.WriteLine(query.ToEsqlString(inlineParameters: false));
// FROM products
// | WHERE price > ?minPrice

var parameters = query.GetParameters();
		

Responses from Elasticsearch come back as rows with typed columns. EsqlClient automatically maps these to your C# types by matching column names to properties (using [JsonPropertyName] attributes or the configured naming policy). Enums, nullable types, and date conversions are handled automatically.

Transport and execution errors are thrown as EsqlExecutionException, which includes the HTTP status code and response body:

try
{
    var results = await client.CreateQuery<LogEntry>()
        .From("logs-*")
        .ToListAsync();
}
catch (EsqlExecutionException ex)
{
    Console.WriteLine($"Status: {ex.StatusCode}");
    Console.WriteLine($"Response: {ex.ResponseBody}");
}