Skip to content

LINQ Provider (IQueryable) & F# Query CE ​

Polars.NET contains a production-grade LINQ expression-tree compiler (Polars.NET.Linq) that translates standard C# IQueryable<T> queries and F# query { } computation expressions directly into native Rust LazyFrame physical plans.

When a query is fully pushed down, Polars executes multithreaded SIMD kernels in Rust and directly yields a DataFrame without allocating a single intermediate .NET row object.

1. .NET Query Frontends
• C# LINQ Query Syntax (from ... select ...)
• C# Method Syntax (.Where().Select())
• F# Query CE (query { for ... select ... })
→
2. Expression Tree Compiler
• Tier 1: 100% Native Pushdown (Rust DAG)
• Tier 2: Hybrid (Native Pushdown + Arrow Cursor)
• Tier 3: Strict Policy / Client Evaluation
→
3. Execution & Materialization
• .ToDataFrame(): Zero managed heap allocations
• .ToLazyFrame(): Escape to raw Polars DSL
• .ToList() / .ToArray(): Typed object hydration

1. Entrypoints & Query Materialization ​

To query a DataFrame or LazyFrame with LINQ, call .AsQueryable<T>(). The query executes lazily—no computation occurs until a terminal method is called:

csharp
using Polars;
using Polars.CSharp;
using Polars.CSharp.Linq;

public record OrderDto(int OrderId, string Customer, string Region, double Revenue);

// 1. Enter LINQ from DataFrame or LazyFrame:
var query = from o in df.AsQueryable<OrderDto>()
            where o.Revenue >= 500.0 && o.Region != "Test"
            group o by o.Region into g
            select new {
                Region = g.Key,
                TotalRevenue = g.Sum(x => x.Revenue),
                OrderCount = g.Count()
            };

// 2. Materialize directly into a Polars DataFrame (Zero .NET Object Allocation):
using DataFrame resultDf = query.ToDataFrame();

// 3. Or inspect the compiled native plan:
Console.WriteLine(query.Explain());
csharp
using Polars;
using Polars.CSharp;
using Polars.CSharp.Linq;

public record OrderDto(int OrderId, string Customer, string Region, double Revenue);

var query = df.AsQueryable<OrderDto>()
    .Where(o => o.Revenue >= 500.0 && o.Region != "Test")
    .GroupBy(o => o.Region)
    .Select(g => new {
        Region = g.Key,
        TotalRevenue = g.Sum(x => x.Revenue),
        OrderCount = g.Count()
    });

using DataFrame resultDf = query.ToDataFrame();
fsharp
open System.Linq
open Polars.FSharp
open Polars.FSharp.Query

[<CLIMutable>]
type OrderDto = {
    OrderId: int
    Customer: string
    Region: string
    Revenue: float
}

// 1. Enter F# query { } computation expression:
let q =
    query {
        for o in df.AsQueryable<OrderDto>() do
        where (o.Revenue >= 500.0 && o.Region <> "Test")
        groupValBy o o.Region into g
        select {|
            Region = g.Key
            TotalRevenue = g.Sum(fun x -> x.Revenue)
            OrderCount = g.Count()
        |}
    }

// 2. Materialize to DataFrame:
use resultDf = q.ToDataFrame()

// 3. Or inspect the compiled native plan:
printfn "%s" (q.Explain())
fsharp
open Polars.FSharp
open Polars.FSharp.Query

// Idiomatic F# pipeline combinators over Query
let resultDf =
    df.AsQueryable<OrderDto>()
    |> Query.where (fun o -> o.Revenue >= 500.0 && o.Region <> "Test")
    |> Query.toDataFrame

Real Engine Plan & Output ​

Here is the exact logical execution plan and materialized DataFrame produced by Polars for the query above:

A. 100% Native Pushdown Plan (query.Explain()) ​

Compiled LINQ Execution Plan (.Explain())
Polars.NET LINQ Plan 100% PUSHDOWN
[Polars.NET Query Plan - 100% Native Pushdown]
---------------------------------------------
simple π 3/3 ["OrderCount", "Region", ... 1 other column]
  AGGREGATE[maintain_order: false]
    [len().alias("OrderCount"), col("Revenue").sum().alias("TotalRevenue")] BY [col("Region")]
    FROM
    FILTER col("Revenue") >= 500.0
    FROM
      DF ["OrderId", "Customer", "Region", "Revenue"]; PROJECT["Region", "Revenue"] 2/4 COLUMNS

B. Materialized Native Output (query.ToDataFrame()) ​

LINQ to Polars 100% Native Pushdown Output
Polars DataFrame: (3 rows, 3 columns)
OrderCountu32RegionstrTotalRevenuef64
2North2090
1East600
2South3450

Direct Pipeline Entrypoints: Collections & ADO.NET Streams ​

Instead of manually creating a DataFrame or LazyFrame first, you can enter the Polars LINQ pipeline directly from in-memory collections or database streams using AsPolarsQueryable:

1. In-Memory Collections (IEnumerable<T>.AsPolarsQueryable()) ​

Directly transposes any List<T>, array, or in-memory sequence into a native Polars LazyFrame and exposes it as a Polars LINQ queryable:

csharp
using Polars.CSharp;
using Polars.CSharp.Linq;

List<OrderDto> orders = GetOrders();

// Ingest in-memory list straight into native Polars Rust execution:
using DataFrame topOrders = orders.AsPolarsQueryable()
    .Where(o => o.Revenue >= 500.0)
    .OrderByDescending(o => o.Revenue)
    .Take(100)
    .ToDataFrame();

2. ADO.NET Database Streams (IDataReader.AsPolarsQueryable()) ​

Streams an open IDataReader directly into a Polars LINQ query with configurable batch size, bypassing intermediate object allocations:

csharp
using System.Data;
using Microsoft.Data.SqlClient;
using Polars.CSharp;
using Polars.CSharp.Linq;

using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT OrderId, Customer, Region, Revenue FROM Orders";
using IDataReader reader = cmd.ExecuteReader();

// Streams database rows straight into Polars LINQ engine:
using DataFrame regionalSales = reader.AsPolarsQueryable<OrderDto>(batchSize: 50_000)
    .Where(o => o.Revenue > 100.0)
    .GroupBy(o => o.Region)
    .Select(g => new {
        Region = g.Key,
        TotalRevenue = g.Sum(x => x.Revenue),
        OrderCount = g.Count()
    })
    .ToDataFrame();

3. Seamless In-Memory Collection Interop (Join, Concat & Union) ​

The Polars LINQ provider seamlessly accepts arbitrary in-memory .NET collections (List<T>, arrays, IEnumerable<T>) as operands inside active queries without manual pre-conversion:

  • In-Memory Join Pushdown: Join a Polars DataFrame query directly with an in-memory lookup collection (List<TInner>). The provider transposes the in-memory sequence into native Arrow batches and executes a Tier-1 100% native pushdown join in Rust:

    csharp
    var inMemoryCustomers = new List<Customer>
    {
        new(1, "Alice"),
        new(2, "Bob")
    };
    
    // query is a Polars queryable over a high-volume DataFrame or LazyFrame:
    var enrichedQuery = ordersQuery.Join(
        inMemoryCustomers, // Arbitrary in-memory List<T>
        o => o.CustomerId,
        c => c.Id,
        (o, c) => new { o.OrderId, c.Name, o.Amount }
    );
    
    using DataFrame resultDf = enrichedQuery.ToDataFrame();
  • In-Memory Concat & Union Pushdown: Append or merge in-memory rows directly into a Polars query:

    csharp
    var extraEmployees = new List<Employee>
    {
        new("Charlie", 35, 70000),
        new("David", 40, 80000)
    };
    
    // Concatenate in-memory collection straight into the native physical plan:
    var combinedQuery = query.Where(e => e.Age >= 25).Concat(extraEmployees);
    using DataFrame combinedDf = combinedQuery.ToDataFrame();

Native Transposition Guarantee

When an in-memory collection participates in a Join, Concat, Union, or Merge operation, Polars.NET automatically transposes the managed objects into Arrow contiguous arrays, pushes execution into native Rust SIMD kernels, and preserves full query optimization.


2. Terminal Methods: What Can You Materialize? ​

Polars.NET LINQ queries support diverse terminal materialization pathways depending on your scenario:

MethodTargetPerformance Characteristics
.ToDataFrame() / .ToDataFrameAsync()DataFrameFastest. 100% native Rust execution; zero .NET object hydration.
.ToLazyFrame()LazyFrameCompiles the LINQ pipeline to a native LazyFrame for continued raw Polars DSL chaining.
.ToSeries(name)SeriesExtracts a single column or composite struct series from a projected query.
.ToList() / .ToArray()List<T> / T[]Materializes typed .NET objects via pre-compiled stack cursors.
await foreach / .ToAsyncEnumerable()IAsyncEnumerable<T>Asynchronous non-blocking streaming without thread starvation.
.First() / .Single() / .Count()ScalarScalar aggregations pushed down to single-row reduction kernels.

3. Supported Native BCL & C# Method Mappings ​

The LINQ provider automatically translates standard .NET Base Class Library (BCL) calls into vectorized Polars expressions:

C# BCL Expressions
s.Name.ToUpper()
s.Created.Year
Math.Round(s.Price, 2)
Regex.IsMatch(s.Email, pattern)
→ Compiled to →
Rust Vectorized Kernels
col("Name").str.to_uppercase()
col("Created").dt.year()
col("Price").round(2)
col("Email").str.contains(pattern)
CategorySupported .NET OperationsCompiled Native Polars Expression
Stringss.Contains(x), s.StartsWith(x), s.EndsWith(x), s.ToUpper(), s.ToLower(), s.Trim(), s.Substring(start, len), s.Replace(a, b), s.Length, string.IsNullOrEmpty(s).Str.Contains(), .Str.StartsWith(), .Str.ToUppercase(), .Str.Slice(), .Str.ReplaceAll(), .Str.LenBytes()
MathematicsMath.Abs(x), Math.Round(x, n), Math.Floor(x), Math.Ceiling(x), Math.Sqrt(x), Math.Pow(x, y), Math.Log(x), Math.Exp(x), Math.Sin(x), Math.Clamp(x, min, max).Abs(), .Round(), .Floor(), .Ceil(), .Sqrt(), .Pow(), .Log(), .Exp(), .Sin(), .Clip()
Date & Timedt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, dt.Second, dt.DayOfWeek, dt.DayOfYear, dt.Date, dt.AddDays(n).Dt.Year(), .Dt.Month(), .Dt.Day(), .Dt.Hour(), .Dt.Minute(), .Dt.Second(), .Dt.Weekday(), .Dt.OffsetBy()
Regular ExpressionsRegex.IsMatch(s, pattern).Str.Contains(pattern) using native Rust high-performance regex engine
Collections & Listsitem.Tags.Contains("promo"), item.Tags.Length, items.SelectMany(x => x.Tags).List.Contains(), .List.Len(), Native .Explode()
Conditionalsx ? a : b, a ?? bNative ternary When.Then.Otherwise and Coalesce kernels

4. The 3-Tier Execution Architecture ​

When you call query.Explain(), Polars.NET reports one of three execution tiers:

Tier 1: [100% Native Pushdown] ​

Every filtering, sorting, grouping, joining, and projection stage is translated into native Polars Rust expressions.

  • Heap Impact: Zero managed heap allocations during execution.
  • Throughput: Maximum SIMD vectorized throughput.

Tier 2: [Hybrid Execution (Partial Pushdown)] ​

Filtering, sorting, and grouping run 100% in Rust. Only custom user code in the final .Select(...) (such as formatting or calling external helper functions) is evaluated on the client via a fast struct cursor:

csharp
var query = df.AsQueryable<Transaction>()
    .Where(t => t.Amount > 100)                      // Native Pushdown (Rust)
    .OrderByDescending(t => t.Amount)               // Native Pushdown (Rust)
    .Select(t => new {                              // Client Evaluation on final row:
        t.Id,
        FormattedDisplay = FormatExternal(t.Amount) // Custom C# method
    });

Tier 3: Client Evaluation Policy & PolarsPushdownException ​

By default, Polars.NET adopts a Strict Policy (ClientEvaluationPolicy.Strict). If an unsupported custom method is used inside an intermediate stage (Where, GroupBy, Join), the provider throws PolarsPushdownException to prevent silent performance degradation:

csharp
// [Error] Throws PolarsPushdownException: CustomMethod cannot be evaluated inside Rust SIMD kernel!
var query = df.AsQueryable<Item>().Where(x => MyCustomHelper(x.Score));

How to Address Pushdown Exceptions: ​

  1. Best Practice: Replace custom methods with supported BCL functions (string, Math, Regex).
  2. Hybrid Projection: Move custom formatting into the terminal .Select(...) clause.
  3. Explicit Client Evaluation: Call .AsEnumerable() (C#) or .AsSeq() (F#) to consciously switch to in-memory LINQ:
    csharp
    // Explicitly offload to client:
    var result = df.AsQueryable<Item>()
        .Where(x => x.Active)               // Pushed down to Polars
        .AsEnumerable()                     // Explicit client barrier
        .Where(x => MyCustomHelper(x.Score)); // Evaluated on client

5. Extended Analytics Operators in LINQ ​

Standard C# LINQ syntax lacks window functions, lag/lead operators, and time-series tools. Polars.CSharp.Linq fills this gap by introducing extension methods usable directly inside LINQ .Select(...):

csharp
using Polars.CSharp.Linq;

var query = df.AsQueryable<Employee>()
    .Select(e => new
    {
        e.Name,
        e.DepartmentId,
        e.Salary,
        // 1. Window Function: Group Average
        DeptAvg = e.Salary.Average().Over(e.DepartmentId),
        
        // 2. Window Deviation
        DiffFromAvg = e.Salary - e.Salary.Average().Over(e.DepartmentId),
        
        // 3. Dense Ranking within department
        DeptRank = e.Salary.Rank(RankMethod.Dense, descending: true).Over(e.DepartmentId),
        
        // 4. Time-series Lead / Lag Shift
        PrevSalary = e.Salary.Shift(1).Over(e.DepartmentId)
    });
csharp
using Polars.CSharp.Linq;

var cleanQuery = df.AsQueryable<SensorReading>()
    .Select(r => new
    {
        r.Timestamp,
        r.DeviceId,
        // Linear interpolation over missing sensor readings partitioned by Device
        CleanVal = r.RawValue.Interpolate(InterpolationMethod.Linear).Over(r.DeviceId)
    });

6. The Bidirectional Escape Hatch ​

No matter how capable a LINQ provider is, advanced analytics occasionally require specialized engine-specific features (such as regex unnesting, Parquet scan tuning, or Aho-Corasick matching). Polars.NET provides a seamless Bidirectional Escape Hatch:

IQueryable<T> (LINQ Provider)
Typed C# queries and F# query { } with BCL expressions
⇄ query.ToLazyFrame() / lf.AsQueryable<T>() ⇄
LazyFrame (Native Polars DSL)
Raw Rust SIMD kernels, Selectors, and Streaming engine

Pattern 1: LINQ to Native LazyFrame (.ToLazyFrame()) ​

Start with familiar LINQ for relational filtering and joining, then seamlessly switch to native Polars DSL for specialized operations:

csharp
// 1. Build initial filtering using comfortable LINQ:
var baseQuery = df.AsQueryable<LogEntry>()
    .Where(e => e.Level == "ERROR" && e.Timestamp >= DateTime.UtcNow.AddDays(-7));

// 2. Escape hatch to native LazyFrame:
using LazyFrame lf = baseQuery.ToLazyFrame();

// 3. Apply raw Polars domain operations (Regex extraction, etc.):
using DataFrame finalDf = lf
    .WithColumns(Pl.Col("Message").Str.ExtractAll(@"\[code:(\d+)\]").Alias("ErrorCodes"))
    .Collect();

Pattern 2: Native LazyFrame to LINQ (.AsQueryable<T>()) ​

Conversely, start with specialized file scanning or partition pruning, and switch back to LINQ:

csharp
// 1. Scan with specialized low-level parquet tuning:
using LazyFrame lf = LazyFrame.ScanParquet("telemetry/*.parquet")
    .Filter(Pl.Col("DeviceType") == "IoT");

// 2. Resume querying with standard LINQ:
var summary = from r in lf.AsQueryable<TelemetryDto>()
              group r by r.Location into g
              select new {
                  Location = g.Key,
                  AvgTemp = g.Average(x => x.Temperature)
              };

7. Polars.NET Exclusive: Strongly-Typed LINQ Merge (TypedMerge) ​

Polars.NET brings full ANSI SQL MERGE semantics directly into the C# and F# LINQ ecosystem:

csharp
using Polars.CSharp;
using Polars.CSharp.Linq;

var targetQuery = targetDf.AsQueryable<ProductItem>();
var sourceQuery = sourceDf.AsQueryable<ProductItem>();

// Perform strongly-typed SQL MERGE using C# expression trees
using DataFrame resultDf = targetQuery.Merge(sourceQuery, t => t.Id, s => s.Id)
    // 1. Matched condition -> Delete
    .WhenMatched(
        (t, s) => s.IsDiscontinued,
        then => then.Delete()
    )
    // 2. Matched condition -> Update modified fields
    .WhenMatched(
        (t, s) => s.Price != t.Price,
        then => then.Update(set => set.Set(t => t.Price, (t, s) => s.Price))
    )
    // 3. Not matched in target -> Insert new item from source
    .WhenNotMatchedByTarget(
        s => s.Price > 0.0,
        then => then.Insert()
    )
    // 4. Not matched in source -> Delete obsolete target item
    .WhenNotMatchedBySource(
        t => t.Category == "Seasonal",
        then => then.Delete()
    )
    .ToDataFrame(); // Returns compiled DataFrame directly!
fsharp
open Polars.FSharp

// F# functional pipeline merge
let mergedDf =
    targetDf.Merge(sourceDf, pl.col "Id")
    |> Merge.whenMatched (pl.col "IsDiscontinued") Merge.delete
    |> Merge.whenMatchedUpdateAll
    |> Merge.whenNotMatchedInsertAll
    |> Merge.executeEager

Direct In-Memory Collection Merge (List<T>.Merge(...)) ​

The strongly-typed Merge builder can also be initiated directly on standard in-memory collections (List<T>, arrays, IEnumerable<T>), merging against another in-memory list or a Polars queryable:

csharp
List<ProductItem> inMemoryTarget = GetTargetProducts();
List<ProductItem> incomingUpdates = GetSourceProducts();

// 1. Initiate full ANSI SQL MERGE directly from an in-memory List<T>:
using DataFrame mergedDf = inMemoryTarget.Merge(incomingUpdates, t => t.Id, s => s.Id)
    .WhenMatched(
        (t, s) => s.Price > t.Price,
        then => then.Update(set => set.Set(t => t.Price, (t, s) => s.Price))
    )
    .WhenNotMatchedByTarget(then => then.Insert())
    .WhenNotMatchedBySource(t => t.Category == "Seasonal", then => then.Delete())
    .ToDataFrame();

// 2. Or materialize directly back to typed entities:
List<ProductItem> updatedList = inMemoryTarget.Merge(incomingUpdates, t => t.Id, s => s.Id)
    .WhenMatched(then => then.Update(set => set.Set(t => t.Price, (t, s) => s.Price)))
    .WhenNotMatchedByTarget(then => then.Insert())
    .ToList();

Next Steps ​

Released under the MIT License. Powered by the Polars Rust core & Apache Arrow.