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);
}
By default, every query path materialises rows into POCOs by streaming the JSON response through the typed reader. For scenarios where you want the unparsed, server-formatted bytes — piping to a file, feeding columnar consumers like Apache Arrow / DataFusion / DuckDB, or doing your own zero-copy decoding — every queryable also exposes raw-stream terminal methods.
Pick the wire format with the EsqlFormat enum:
| Value | Media type | Notes |
|---|---|---|
Json |
application/json |
Same envelope the typed reader consumes, but returned unparsed |
Csv |
text/csv |
One header row plus data |
Tsv |
text/tab-separated-values |
Tab-delimited variant |
Txt |
text/plain |
Human-readable ASCII table |
Arrow |
application/vnd.apache.arrow.stream |
Apache Arrow IPC stream |
Smile |
application/smile |
Binary JSON variant |
Cbor |
application/cbor |
CBOR |
Yaml |
application/yaml |
YAML |
using var stream = await client.CreateQuery<LogEntry>()
.From("logs-*")
.Where(l => l.Level == "ERROR")
.ToStreamAsync(EsqlFormat.Csv);
await stream.CopyToAsync(File.Create("errors.csv"));
ToStreamAsync(format) returns a Stream. Disposing the stream releases the underlying HTTP connection — the response wrapper is owned by the returned stream.
A synchronous overload ToStream(format) is available for non-async call sites. On .NET 10+, ToPipeReaderAsync(format) returns a PipeReader for zero-copy consumers.
For long-running queries with a non-JSON format, use ToAsyncQueryAsync(format). This mirrors ToAsyncQueryAsync() but returns a non-generic EsqlAsyncQuery because there is no T to materialise into:
await using var q = await client.CreateQuery<LogEntry>()
.From("logs-*")
.Where(l => l.Level == "ERROR")
.ToAsyncQueryAsync(EsqlFormat.Arrow);
await q.WaitForCompletionAsync();
using var stream = q.GetResponseStream();
using var reader = new ArrowStreamReader(stream);
while (await reader.ReadNextRecordBatchAsync() is { } batch)
Console.WriteLine($"Batch: {batch.Length} rows, {batch.ColumnCount} cols");
- Apache.Arrow.Ipc — separate NuGet
Disposing the EsqlAsyncQuery issues a best-effort DELETE /_query/async/{id} and releases the held response.
Calling GetResponseStream() before completion throws InvalidOperationException. Use RefreshAsync() for a single poll or WaitForCompletionAsync() to poll until done (default 100 ms interval).
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.
Run KNN, exact similarity, and hybrid (lexical + semantic) search using DenseVector<T> vector parameters and the Fork(...) / Fuse(...) extensions:
var queryVec = new float[] { 0.12f, -0.03f, 0.98f /* ... */ };
var results = await client.CreateQuery<Book>()
.From("books", MetadataField.Id | MetadataField.Index | MetadataField.Score)
.Fork(
b => b.Where(x => EsqlFunctions.Match(x.Title, "shakespeare")).Take(50),
b => b.Where(x => EsqlFunctions.Knn(x.TitleVec, queryVec)).Take(50))
.Fuse()
.OrderByDescending(_ => EsqlMetadata.Score)
.Take(10)
.ToListAsync();
See the vector and hybrid search docs for the full API surface, including KNN options, TEXT_EMBEDDING, V_* similarity functions, FORK/FUSE configuration, and EsqlMetadata markers.
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}");
}