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, and the serializer settings of the context's own options (converters, number handling, case sensitivity, and so on) carry over to materialization. You can also set JsonSerializerOptions directly for non-AOT scenarios:
var settings = new EsqlClientSettings(transport)
{
JsonSerializerOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
}
};
Types that the context does not declare are resolved through the reflection-based default
resolver. Under Native AOT that fallback still handles types with an explicit [JsonConverter]
attribute; any other type must be declared in the context, or materialization fails at runtime.
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)
.AsEsqlQueryable()
.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 }
).AsEsqlQueryable().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)
.AsEsqlQueryable()
.ToListAsync();
It works with all execution styles -- lambda, query syntax, and streaming:
await foreach (var entry in client.QueryAsync<LogEntry>(q => q
.AsEsqlQueryable()
.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")
.AsEsqlQueryable()
.ToAsyncQueryAsync(new EsqlAsyncQueryOptions
{
WaitForCompletionTimeout = TimeSpan.FromSeconds(5),
KeepAlive = TimeSpan.FromMinutes(10)
});
await asyncQuery.WaitForCompletionAsync();
var results = asyncQuery.ToList();
Or via the EsqlClient convenience methods:
await using var asyncQuery = await client.SubmitAsyncQueryAsync<LogEntry>(
q => q.AsEsqlQueryable()
.WithOptions(new EsqlQueryOptions { TimeZone = "UTC" })
.From("logs-*")
.Where(l => l.Level == "ERROR"),
new EsqlAsyncQueryOptions { KeepOnCompletion = true }
);
Protocol-level options live on EsqlQueryOptions (from Elastic.Esql) and work with any executor:
| Option | Type | Description |
|---|---|---|
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") |
Transport-level options live on EsqlTransportOptions:
| Option | Type | Description |
|---|---|---|
RequestConfiguration |
IRequestConfiguration? |
Per-request transport overrides |
EsqlTransportOptions is specific to Elastic.Clients.Esql. Other downstream implementations may define their own WithOptions extensions with different option types; such methods must be marked with [EsqlQueryOptionsMethod] (from Elastic.Esql.Extensions) so the translator recognizes them. Setting the same options slot more than once in a query chain throws InvalidOperationException.
Use RequestConfiguration to control transport behavior per query -- for example, custom timeouts, authentication, or headers:
var results = await client.CreateQuery<LogEntry>()
.WithOptions(new EsqlTransportOptions
{
RequestConfiguration = new RequestConfiguration
{
RequestTimeout = TimeSpan.FromSeconds(120),
Authentication = new BasicAuthentication("user", "pass"),
Headers = new NameValueCollection { { "X-Custom-Header", "value" } }
}
})
.From("logs-*")
.AsEsqlQueryable()
.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")
.AsEsqlQueryable()
.CountAsync();
var hasErrors = await client.CreateQuery<LogEntry>()
.From("logs-*")
.Where(l => l.Level == "ERROR")
.AsEsqlQueryable()
.AnyAsync();
var first = await client.CreateQuery<LogEntry>()
.From("logs-*")
.Where(l => l.Level == "ERROR")
.AsEsqlQueryable()
.FirstOrDefaultAsync();
var single = await client.CreateQuery<LogEntry>()
.From("logs-*")
.Where(l => l.Level == "ERROR")
.Take(1)
.AsEsqlQueryable()
.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).AsEsqlQueryable();
await foreach (var entry in query.AsAsyncEnumerable())
{
ProcessEntry(entry);
}
Flat result types are bound directly from the response reader, property by property, without an intermediate JSON document. A type qualifies for this path when all of the following hold:
- it is a class or struct with a parameterless constructor and no
requiredmembers; - it has no
OnDeserializingorOnDeserializedcallbacks; - every result column maps to a settable property;
- every mapped property is a
string,bool,int,long,double,float,decimal,DateTime,DateTimeOffsetorGuid, or the nullable form of one of these; - no mapped property uses a custom converter, whether declared with a
JsonConverterattribute or registered on theJsonSerializerOptions; only the framework's built-in converters qualify, and enums are not used.
Anything else, including nested object columns, falls back to assembling each row as a JSON
object and deserializing it with System.Text.Json. The fallback is correct but slower, and
nothing signals which path is active. If throughput matters, shape the result type to the list
above. A row whose cell does not match the property type is retried through the fallback, so the
serializer produces the same value coercion or error either way. The same retry happens when a
streamed row is cut by a chunk boundary and completes on the next read. Each retried row allocates
and discards one partially bound instance of the type; a constructor or property setter with side
effects therefore runs more than once for such rows.
Nested result types are deserialized in batches of up to 64 rows or 64 KB, so the first row
becomes available after the first batch rather than immediately. Batching also requires the
serializer to resolve type metadata for List<T> through the framework's own list converter; a
source-generated context that does not declare List<T> for the result type, or options that
register a custom converter for List<T>, stream rows one at a time instead. When a row inside a
batch is malformed or does not match the type, the rows before it are still delivered and the
error surfaces at the faulty row, the same as for flat types.
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")
.AsEsqlQueryable()
.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. The format argument is optional on all three methods; when omitted, the query's configured format applies, defaulting to JSON.
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")
.AsEsqlQueryable()
.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 releases the held response and issues a best-effort DELETE /_query/async/{id}. The delete still runs when releasing the response throws.
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
await asyncQuery.WaitForCompletionAsync();
var results = asyncQuery.ToList();
await using var asyncQuery = await client.CreateQuery<LogEntry>()
.From("logs-*")
.Where(l => l.Level == "ERROR")
.AsEsqlQueryable()
.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")
.AsEsqlQueryable()
.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)
.AsEsqlQueryable()
.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-*")
.AsEsqlQueryable()
.ToListAsync();
}
catch (EsqlExecutionException ex)
{
Console.WriteLine($"Status: {ex.StatusCode}");
Console.WriteLine($"Response: {ex.ResponseBody}");
}