<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
  <channel>
    <title><![CDATA[Martijn Laarman - Elasticsearch Labs]]></title>
    <description><![CDATA[Articles and tutorials from the Search team at Elastic]]></description>
    <copyright><![CDATA[© 2026. Elasticsearch B.V. All Rights Reserved]]></copyright>
    <image>
      <title><![CDATA[Martijn Laarman - Elasticsearch Labs]]></title>
      <url>https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1121c0bf0e8a6e65/6a88da6340a1841030ef456f/search-labs-thumbnail.png</url>
      <link>https://www.elastic.co/search-labs/author/martijn-laarman</link>
    </image>
    <link>https://www.elastic.co/search-labs/author/martijn-laarman</link>
    <atom:link href="https://www.elastic.co/search-labs/rss/author/martijn-laarman.xml" rel="self" type="application/rss+xml"/>
    <language><![CDATA[en]]></language>
    <lastBuildDate>Fri, 18 Sep 2026 17:51:22 GMT</lastBuildDate>
  <item>
    <title><![CDATA[LINQ to Elasticsearch ES|QL: Write C#, query Elasticsearch]]></title>
    <description><![CDATA[Exploring the new LINQ to Elasticsearch ES|QL provider in the Elasticsearch .NET client, which allows you to write C# code that’s automatically translated to ES|QL queries.]]></description>
    <content:encoded><![CDATA[<p>Starting with <strong>v9.3.4</strong> and <strong>v8.19.18</strong>, the Elasticsearch .NET client includes a <a href="https://learn.microsoft.com/en-us/dotnet/csharp/linq/">Language Integrated Query (LINQ) </a>provider that translates C# LINQ expressions into <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql.html">Elasticsearch Query Language (ES|QL)</a> queries at runtime. Instead of writing ES|QL strings by hand, you compose queries using <code>Where</code>, <code>Select</code>, <code>OrderBy</code>, <code>GroupBy</code>, and other standard operators. The provider takes care of translation, parameterization, and result deserialization, including per-row streaming that keeps memory usage constant, regardless of result set size.</p><h2>Your first query</h2><p>Start by defining a plain old CLR object (POCO) that maps to your Elasticsearch index. Property names are resolved to ES|QL column names through standard <code>System.Text.Json</code> attributes, like <code>[JsonPropertyName]</code>, or through a configured <code>JsonNamingPolicy</code>. The same <a href="https://www.elastic.co/docs/reference/elasticsearch/clients/dotnet/source-serialization">source serialization</a> rules that apply across the rest of the client apply here as well.</p>using System.Text.Json.Serialization;

public class Product
{
    [JsonPropertyName("product_id")]
    public string Id { get; set; }

    public string Name { get; set; }

    public string Brand { get; set; }

    [JsonPropertyName("price_usd")]
    public double Price { get; set; }

    [JsonPropertyName("in_stock")]
    public bool InStock { get; set; }
}<p>With the type in place, a query looks like this:</p>var minPrice = 100.0;
var brand = "TechCorp";

await foreach (var product in client.Esql.QueryAsync&lt;Product&gt;(q =&gt; q
    .From("products")
    .Where(p =&gt; p.InStock &amp;&amp; p.Price &gt;= minPrice &amp;&amp; p.Brand == brand)
    .OrderByDescending(p =&gt; p.Price)
    .Take(10)))
{
    Console.WriteLine($"{product.Name}: ${product.Price}");
}<p>The provider translates this into the following ES|QL:</p><p>A few details to note:</p><ul><li><p><strong>Property name resolution:</strong> <code>p.Price</code> becomes <code>price_usd</code> because of the <code>[JsonPropertyName]</code> attribute, and <code>p.Brand</code> becomes <code>brand</code> following the default camelCase naming policy.</p></li><li><p><strong>Parameter capturing:</strong> The C# variables <code>minPrice</code> and <code>brand</code> are captured as named parameters (<code>?minPrice</code>, <code>?brand</code>). They’re sent separately from the query string in the JSON payload, which prevents injection and enables server-side query plan caching.</p></li><li><p><strong>Streaming:</strong> <code>QueryAsync&lt;T&gt;</code> returns <code>IAsyncEnumerable&lt;T&gt;</code>. Rows are materialized one at a time as they arrive from Elasticsearch.</p></li></ul><p>You can also inspect the generated query and its parameters without executing it:</p>var query = client.Esql.CreateQuery&lt;Product&gt;()
    .Where(p =&gt; p.InStock &amp;&amp; p.Price &gt;= minPrice &amp;&amp; p.Brand == brand)
    .OrderByDescending(p =&gt; p.Price)
    .Take(10);

Console.WriteLine(query.ToEsqlString());
// FROM products | WHERE (in_stock == true AND price_usd &gt;= 100) | SORT price_usd DESC | LIMIT 10

Console.WriteLine(query.ToEsqlString(inlineParameters: false));
// FROM products | WHERE (in_stock == true AND price_usd &gt;= ?minPrice AND brand == ?brand) | SORT price_usd DESC | LIMIT 10

var parameters = query.GetParameters();
// { "minPrice": 100.0, "brand": "TechCorp" }<h2>How does this work? A quick LINQ refresher</h2><p>The mechanism that makes LINQ providers possible is the distinction between <code>IEnumerable&lt;T&gt;</code> and <code>IQueryable&lt;T&gt;</code>.</p><p>When you call <code>.Where(p =&gt; p.Price &gt; 100)</code> on an <code>IEnumerable&lt;T&gt;</code>, the lambda compiles to a <code>Func&lt;Product, bool&gt;</code>, a regular delegate that the runtime executes in-process. This is LINQ-to-Objects.</p><p>When you call the same method on an <code>IQueryable&lt;T&gt;</code>, the C# compiler wraps the lambda in an <code>Expression&lt;Func&lt;Product, bool&gt;&gt;</code> instead. This is a data structure that represents the <em>structure</em> of the code rather than its executable form. The expression tree can be inspected, analyzed, and translated into another language at runtime.</p>// IEnumerable: the lambda is a compiled delegate
IEnumerable&lt;Product&gt; local = products.Where(p =&gt; p.Price &gt; 100);

// IQueryable: the lambda is an expression tree, a data structure
IQueryable&lt;Product&gt; remote = queryable.Where(p =&gt; p.Price &gt; 100);<p>The <code>IQueryProvider</code> interface is the extension point. Any provider can implement <code>CreateQuery&lt;T&gt;</code> and <code>Execute&lt;T&gt;</code> to translate these expression trees into a target language. Entity Framework uses this to emit SQL. The LINQ to ES|QL provider uses it to emit ES|QL.</p><p>The expression tree for the query above looks like this:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt521838e8b9c36649/6a1705b1839dfa5f40dcfdfe/f864cd18a390831f8d28503a29b5835efb1842f7-1000x720.png" alt="Expression tree for the example query." /><p><em>Expression tree for the example query.</em></p><p>The tree is nested inside out: <code>Take</code> wraps <code>OrderByDescending</code>, which wraps <code>Where</code>, which wraps <code>From</code>, which wraps the root <code>EsqlQueryable&lt;Product&gt;</code> constant. The <code>Where</code> predicate is itself a subtree of <code>BinaryExpression</code> nodes for the <code>&amp;&amp;</code>, <code>&gt;=</code>, and <code>==</code> operators, with <code>MemberExpression</code> leaves for property accesses and closure captures for the <code>minPrice</code> and <code>brand</code> variables. This is the data structure that the provider walks to produce the final ES|QL.</p><h2>Under the hood: The translation pipeline</h2><p>The path from a LINQ expression to query results follows a six-stage pipeline:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt930670a505dd61ea/6a1705b3b339d58a54769ecf/2a2c772b63d720f61fc9a28b2f85668fa2db8d38-1999x1036.png" alt="Translation pipeline overview." /><p><em>Translation pipeline overview.</em></p><h3>1. Expression tree capture</h3><p>When you chain <code>.Where()</code>, <code>.OrderBy()</code>, <code>.Take()</code> and other operators on an <code>IQueryable&lt;T&gt;</code>, the standard LINQ infrastructure builds an expression tree. <code>EsqlQueryable&lt;T&gt;</code> implements <code>IQueryable&lt;T&gt;</code> and delegates to <code>EsqlQueryProvider</code>.</p><h3>2. Translation</h3><p>When the query is executed (by enumerating, calling <code>ToList()</code>, or using <code>await foreach)</code>, the <code>EsqlExpressionVisitor</code> walks the expression tree inside out. It dispatches each LINQ method call to a specialized visitor:</p><p>Visitor</p><p>Translates</p><p>Into</p><p>WhereClauseVisitor</p><p>.Where(predicate)</p><p>WHERE condition</p><p>SelectProjectionVisitor</p><p>.Select(selector)</p><p>EVAL + KEEP + RENAME</p><p>GroupByVisitor</p><p>.GroupBy().Select()</p><p>STATS ... BY</p><p>OrderByVisitor</p><p>.OrderBy() / .ThenBy()</p><p>SORT field [ASC\|DESC]</p><p>EsqlFunctionTranslator</p><p>EsqlFunctions.*, Math.*, string methods</p><p>80+ ES|QL functions</p><p>During translation, C# variables referenced in expressions are captured as named parameters.</p><h3>3. Query model</h3><p>The visitors don’t produce strings directly. Instead, they produce <code>QueryCommand</code> objects, an immutable intermediate representation. A <code>FromCommand</code>, a <code>WhereCommand</code>, a <code>SortCommand</code>, and a <code>LimitCommand</code>, each representing one ES|QL processing command. These are collected into an <code>EsqlQuery</code> model.</p><p></p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt788c9936976f2f62/6a1705b50e2e4910da419ff0/2adc349b6cf655b96b7b3e826a134e8a17fe42fd-1999x1036.png" alt="Query model and command pattern." /><p><em>Query model and command pattern.</em></p><p>This intermediate model is decoupled from both the expression tree and the output format. It can be inspected, intercepted (via <code>IEsqlQueryInterceptor</code>), or modified before formatting.</p><h3>4. Formatting</h3><p><code>EsqlFormatter</code> visits each <code>QueryCommand</code> in order and produces the final ES|QL string. Each command becomes one line, separated by the pipe (|) operator that ES|QL uses to chain processing commands. Identifiers containing special characters are automatically escaped with backticks.</p><h3>5. Execution</h3><p>The formatted ES|QL string and captured parameters are sent to Elasticsearch’s <code>/_query</code> endpoint as a JSON payload. The <code>IEsqlQueryExecutor</code> interface abstracts the transport layer, which is where the layered package architecture comes into play.</p><h3>6. Materialization</h3><p><code>EsqlResponseReader</code> streams the JSON response without buffering the entire result set into memory. A <code>ColumnLayout</code> tree, precomputed once per query, maps flat ES|QL column names (like <code>address.street</code>, <code>address.city</code>) to nested POCO properties. Each row is assembled into a <code>T</code> instance and yielded one at a time via <code>IEnumerable&lt;T&gt;</code> or <code>IAsyncEnumerable&lt;T&gt;</code>.</p><h2>The layered architecture</h2><p>The LINQ to ES|QL functionality is split across three packages:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt662bd0dd8861b6b6/6a1705b7a929cf7086ae08a2/41b8aae860ecdc2480edcb1c1d4cc9b03cfb78c9-1999x1036.png" alt="Package architecture." /><p><em>Package architecture.</em>
<a href="https://www.nuget.org/packages/Elastic.Esql"><strong><code>Elastic.Esql</code></strong></a> is the pure translation engine. It has zero HTTP dependencies and contains the expression visitors, query model, formatter, and response reader. You can use it stand alone to build and inspect ES|QL queries without an Elasticsearch connection, which is useful for testing, query logging, or building your own execution layer.</p>// Translation-only: no Elasticsearch connection needed
var provider = new EsqlQueryProvider();
var query = new EsqlQueryable&lt;Product&gt;(provider)
    .From("products")
    .Where(p =&gt; p.InStock)
    .OrderByDescending(p =&gt; p.Price);

Console.WriteLine(query.ToEsqlString());
// FROM products | WHERE in_stock == true | SORT price_usd DESC<p><a href="https://www.nuget.org/packages/Elastic.Clients.Esql"><strong><code>Elastic.Clients.Esql</code></strong></a> is a lightweight stand-alone ES|QL client. It adds HTTP execution on top of <code>Elastic.Esql</code> via <code>Elastic.Transport</code>. If your application only needs ES|QL and none of the other Elasticsearch APIs, this is the minimal dependency option.</p><p><a href="https://www.nuget.org/packages/Elastic.Clients.Elasticsearch"><strong><code>Elastic.Clients.Elasticsearch</code></strong></a> is the full Elasticsearch .NET client. It also builds on <code>Elastic.Esql</code> and exposes the LINQ provider through the <code>client.Esql</code> namespace. This is the recommended entry point for most applications.</p><p>Both execution-layer packages provide their own implementation of <code>IEsqlQueryExecutor</code>, the strategy interface that bridges translation and transport.</p><p>All three packages are compatible with Native AOT when used with a source-generated <code>JsonSerializerContext</code>. For the full client, see the <a href="https://www.elastic.co/docs/reference/elasticsearch/clients/dotnet/source-serialization#native-aot">Native AOT documentation</a>.</p><h2>Beyond the basics</h2><p>The example above covered filtering, sorting, and pagination. The provider supports a broader set of operations.</p><h3>Aggregations</h3><p><code>GroupBy</code>, combined with aggregate functions in <code>Select</code>, translates to ES|QL <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/stats-by"><code>STATS ... BY</code></a>:</p>var stats = client.Esql.Query&lt;Product, object&gt;(q =&gt; q
    .GroupBy(p =&gt; p.Brand)
    .Select(g =&gt; new
    {
        Brand = g.Key,
        Count = g.Count(),
        AvgPrice = g.Average(p =&gt; p.Price),
        MaxPrice = g.Max(p =&gt; p.Price)
    }));

// -&gt; FROM products | STATS COUNT(*), AVG(price_usd), MAX(price_usd) BY brand<h3>Projections</h3><p><code>Select</code>, with anonymous types generates <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/eval"><code>EVAL</code></a>, <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/keep"><code>KEEP</code></a>, and <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/rename"><code>RENAME</code></a> commands:</p>var query = client.Esql.CreateQuery&lt;Product&gt;()
    .Select(p =&gt; new { ProductName = p.Name, p.Price, p.InStock });

// -&gt; FROM products | KEEP name, price_usd, in_stock | RENAME name AS ProductName<h3>Rich function library</h3><p>Over 80 ES|QL functions are available through the <code>EsqlFunctions</code> class, covering date/time, string, math, IP, pattern matching, and scoring. Standard <code>Math.*</code> and <code>string.*</code> methods are also translated:</p>.Where(p =&gt; p.Name.Contains("Pro"))       // -&gt; WHERE name LIKE "*Pro*"
.Where(p =&gt; EsqlFunctions.CidrMatch(      // -&gt; WHERE CIDR_MATCH(ip, "10.0.0.0/8")
    p.IpAddress, "10.0.0.0/8"))<h3>LOOKUP JOIN</h3><p>Cross-index lookups translate to ES|QL <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/lookup-join"><code>LOOKUP JOIN</code></a>:</p>var enriched = client.Esql.Query&lt;Product, object&gt;(q =&gt; q
    .LookupJoin&lt;Product, CategoryLookup, string, object&gt;(
        "category-lookup-index",
        product =&gt; product.Id,
        category =&gt; category.CategoryId,
        (product, category) =&gt; new { product.Name, category!.CategoryLabel }));<h3>Raw ES|QL escape hatch</h3><p>For ES|QL features not yet covered by the LINQ provider, you can append raw fragments:</p>var results = client.Esql.Query&lt;Product&gt;(q =&gt; q
    .Where(p =&gt; p.InStock)
    .RawEsql("| EVAL discounted = price_usd * 0.9"));<h3>Server-side async queries</h3><p>For long-running queries, submit them for background processing on the server:</p>await using var asyncQuery = await client.Esql.SubmitAsyncQueryAsync&lt;Product&gt;(
    q =&gt; q.Where(p =&gt; p.InStock),
    asyncQueryOptions: new EsqlAsyncQueryOptions
    {
        WaitForCompletionTimeout = TimeSpan.FromSeconds(5),
        KeepAlive = TimeSpan.FromMinutes(10)
    });

await asyncQuery.WaitForCompletionAsync();
await foreach (var product in asyncQuery.AsAsyncEnumerable())
    Console.WriteLine(product.Name);<p>Server-side async queries are especially useful for long-running analytical queries / large dataset processing that might exceed typical timeout thresholds, or in timeout-sensitive environments with load balancers, API gateways, or proxies that enforce strict HTTP timeouts. Async queries avoid connection drops by decoupling submission from result retrieval.</p><h2>Getting started</h2><p>LINQ to ES|QL is available starting from:</p><ul><li><p><strong>Elastic.Clients.Elasticsearch v9.3.4</strong> (9.x branch)</p></li><li><p><strong>Elastic.Clients.Elasticsearch v8.19.18</strong> (8.x branch)</p></li></ul><p>Install from NuGet:</p><p><code>dotnet add package Elastic.Clients.Elasticsearch</code></p><p>The entry points are on <code>client.Esql</code>:</p><p>Method</p><p>Returns</p><p>Use case</p><p>Query&lt;T&gt;(...)</p><p>IEnumerable&lt;T&gt;</p><p>Synchronous execution</p><p>QueryAsync&lt;T&gt;(...)</p><p>IAsyncEnumerable&lt;T&gt;</p><p>Async streaming</p><p>CreateQuery&lt;T&gt;()</p><p>IEsqlQueryable&lt;T&gt;</p><p>Advanced composition and inspection</p><p>SubmitAsyncQueryAsync&lt;T&gt;(...)</p><p>EsqlAsyncQuery&lt;T&gt;</p><p>Long-running server-side queries</p><p>For the full feature reference, including query options, multifield access, nested objects, and multivalue field handling, see the <a href="https://www.elastic.co/docs/reference/elasticsearch/clients/dotnet/linq-to-esql">LINQ to ES|QL documentation</a>.</p><h2>Conclusion</h2><p>LINQ to ES|QL brings the full expressiveness of C# LINQ to Elasticsearch's ES|QL query language, letting you write strongly typed, composable queries without handcrafting query strings. With automatic parameter capturing, streaming materialization, and a layered package architecture that scales from stand-alone translation to the full Elasticsearch client, it fits naturally into .NET applications of any size. Install the latest client, point your LINQ expressions at an index, and let the provider handle the rest.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/linq-esql-c-elasticsearch-net-client</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/linq-esql-c-elasticsearch-net-client</guid>
    <category><![CDATA[ES|QL]]></category>
    <category><![CDATA[Vector Database]]></category>
    <dc:creator><![CDATA[Florian Bernd,Martijn Laarman]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltdfa35fbcbbf4959f/6a1705b9dc55de19a4e00d07/e54132e915217063e9ed0ec45059c6cfc38e31dd-1280x720.png" length="0" type="image/png"/>
    <pubDate>Wed, 01 Apr 2026 00:00:00 GMT</pubDate>
  </item>
  </channel>
</rss>