07. Generics & LINQ - C#
Quote
“All non-trivial abstractions, to some degree, are leaky.”
— Joel Spolsky, The Law of Leaky Abstractions, blog post (2002)
Summary
Generics
- Type parameters (
<T>,<TKey, TValue>) are placeholders the compiler resolves at each usage site — eliminating boxing for value types and catching type mismatches at compile time.- Constraints (
where T : IComparable<T>,where T : new(),where T : class/struct,where T : notnull,where T : unmanaged) narrow the allowed types and unlock interface methods or constructors inside the generic body.- Variance:
out T(covariance) allows a more-derived type where a base is expected on read-only interfaces;in T(contravariance) does the same for write-only consumption. Both work only on interfaces and delegates.Advanced LINQ — Core Operators
Wherefilters,Selectprojects,OrderBy/OrderByDescendingsorts,Take/Skippages — all build a lazy pipeline that executes only when enumerated.GroupBypartitions a sequence intoIGrouping<TKey, TElement>groups for per-group aggregates (Count, Average, Sum, Min, Max, MaxBy).Joinperforms an inner join on matching keys;GroupJoinproduces a left-join hierarchy grouping all right-side matches under each left element.ToLookupbuilds a multi-value dictionary (unlikeToDictionary, it does not throw on duplicate keys).Zippairs elements from two or three sequences positionally, stopping at the shortest.SelectManyflattens a projected collection — equivalent to a nested loop or SQLCROSS APPLY.LINQ Analytics on Live SQL Server Data
- Data loaded from
stoxxvia Dapper intoList<Ohlcv>(66 K rows) andList<ScoreRow>(466 rows).GroupBy+Selectaggregates map to SQLGROUP BY; chainedGroupBy+SelectManywith index maps toROW_NUMBER() OVER (PARTITION BY ...).LAG()viaZipon a shifted list;LEAD()viaZipwithSkip(1); cumulativeSUM()viaAggregatewith an accumulator list; rolling average viaEnumerable.Range+Skip/Take.NTILE(N)via index arithmetic afterOrderBy;HAVINGas aWhereafterGroupBy + Select;STDEVcomputed manually (no built-in LINQ equivalent).- Cross-sequence
Joinpairs OHLCV aggregates with composite scores by symbol; nestedGroupBycomputes best stock per sector.LINQ vs Polars.NET — Side-by-Side
- Polars.NET reads Parquet directly into a columnar Rust-backed DataFrame; LINQ operates on in-memory
IEnumerable<T>from Dapper.- Column selection:
Select(...)vsdf.Select(...). Row filter:Where(...)vsdf.Filter(Col(...)). Sort:OrderByvsdf.Sort(...). Computed column:Selectanonymous type vsdf.WithColumns(...).- Aggregation: LINQ
GroupBy + Selectvsdf.GroupBy(...).Agg(...). HAVING:WhereafterSelectvsdf.FilterafterAgg.- Window functions: LINQ uses
Zip/Aggregate/Enumerable.Range; Polars uses.Shift(1).Over(...),.CumSum().Over(...),.RollingMean("20i").Over(...),.Rank(...).Over(...).- CRUD-like:
Concat(LINQ) /VStack(Polars) to append rows;Selectprojection (LINQ) /WithColumns(Polars) to update;Wherenegation (LINQ) /Filternegation (Polars) to delete.
Glossary
Generic type parameter
A placeholder type declared in angle brackets —
<T>,<TKey, TValue>— that the compiler substitutes with a concrete type at each usage site (e.g.,List<int>,Dictionary<string, int>). One generic class or method handles any type without duplication and without losing compile-time type safety.Equivalent in Python:
TypeVarfrom thetypingmodule; in Java: bounded wildcards<T extends Comparable<T>>.Infer vs. specify
The compiler infers
Tfrom the argument when possible (First(new[] { 1, 2, 3 })infersint). Specify explicitly (First<string>(...)) only when inference is ambiguous or you want to be explicit for readability.
Type constraint
A
where T : ...clause that restricts which types are valid for a type parameter, enabling the compiler to guarantee that operations onT— such as.CompareTo(),new T(), or specific interface members — are safe to call inside the generic body.Common constraints:
where T : IComparable<T>(enables comparison),where T : new()(enablesnew T()),where T : class(reference types only),where T : struct(value types only),where T : notnull,where T : unmanaged(no reference-type fields),where T : BaseClass,where T : U(T derives from another type parameter).Over-constraining reduces reusability
Adding constraints that are not needed by the generic body silently narrows the set of usable types. Apply only the constraints the body actually requires.
where T : new()
A constraint that requires
Tto expose a public parameterless constructor, enablingnew T()inside the generic class or method. Must appear last when combined with other constraints.Any type with only parameterized constructors does not satisfy
new(). Use a factory delegateFunc<T>as a parameter instead when parameterless construction is not guaranteed.Factory delegate pattern
T Create<T>(Func<T> factory) => factory();avoids thenew()constraint entirely and gives the caller full control over howTis constructed.
Covariance (
out T)
Declared with the
outmodifier on an interface or delegate type parameter (e.g.,IEnumerable<out T>). Allows a more-derived type to be used where a base type is expected — for example, passingIEnumerable<Dog>whereIEnumerable<Animal>is required. Safe only for read (output) positions.Applies exclusively to interfaces and delegates; generic classes are invariant. The
outmodifier preventsTfrom appearing in input positions (e.g., as a method parameter), which is what makes the variance type-safe.IEnumerable is covariant by design
IEnumerable<out T>is covariant in .NET because it only producesT(viaGetEnumerator) and never consumes it. This is why aList<string>can be passed to a method that acceptsIEnumerable<object>.
Contravariance (
in T)
Declared with the
inmodifier on an interface or delegate type parameter (e.g.,Action<in T>,IComparer<in T>). Allows a less-derived (broader) type to be used where a more-derived type is expected — for example, passingAction<Animal>whereAction<Dog>is required. Safe only for write (input) positions.Mixing
inandouton the same type parameter causes a compile error. A type parameter can be covariant or contravariant, never both.Practical use: comparers and handlers
IComparer<Animal>satisfiesIComparer<Dog>(contravariance) — anAnimalComparercan sort dogs because dogs are animals. Pass a broader comparer or event handler where a narrower one is expected.
LINQ
Language Integrated Query — a set of extension methods (
System.Linq) and optional query syntax keywords that add a declarative, composable pipeline model for filtering, transforming, grouping, and joining anyIEnumerable<T>orIQueryable<T>directly in C#.Replaces manual
foreachloops with readable, chainable expressions that preserve strong typing and compose lazily. The same operators apply to in-memory collections, LINQ to SQL, Entity Framework, and XML (XDocument).LINQ is lazy — pipelines re-execute on each enumeration
A LINQ query builds a description of the operation, not the result. Every call to
foreach,.Count(), or.ToList()re-runs the pipeline from source. If the source is expensive (database, file), materialize with.ToList()or.ToArray()before enumerating more than once.
Deferred execution
The property of LINQ pipelines whereby query evaluation is postponed until a consuming operation enumerates the result —
.ToList(),.ToArray(),foreach,.Count(),.First(), etc. The query object holds the pipeline description, not the data.Enables composable query building: partial pipelines can be stored in variables and extended before materialization. Also avoids computing results that are never consumed.
When to materialize early
Materialize with
.ToList()when: (1) the source collection may change between enumerations, (2) the pipeline is expensive and the result is needed more than once, (3) you need random access by index, or (4) you need to pass the result to a method expectingIList<T>.
Method syntax
LINQ expressed as a chain of extension-method calls:
data.Where(x => x > 5).Select(x => x * 2).OrderBy(x => x). The dominant style in production C# because it is composable, tooling-friendly, and supports all LINQ operators.Compiled to the same IL as query syntax. Some operators —
Distinct,Take,Skip,Zip,SelectManywith a result selector — have no query syntax equivalent and require method syntax.Chain readability tip
Break long method-syntax chains onto separate lines, one operator per line, aligned at the dot. The compiler treats the whole expression as one statement; formatting is cosmetic.
Query syntax
LINQ expressed with SQL-like keywords:
from x in data where x > 5 select x * 2. Compiled identically to method syntax. More readable for complex multi-source joins andletbindings that would produce deeply nested lambda arguments.Not all LINQ operators have query-syntax equivalents:
Distinct,Take,Skip,Zip,Aggregate, andToLookuprequire method syntax or a hybrid expression.Query syntax is syntactic sugar
The C# compiler transforms every query-syntax expression into an equivalent method-syntax call tree before compilation. The two forms produce identical IL — choose based on readability for the specific query.
Select
Projects each element of a sequence into a new form using a selector function:
data.Select(x => new { x.Name, x.Age }). Equivalent to SQLSELECTor Pythonmap(). Returns a newIEnumerable<TResult>of the projected type without filtering the source.Confusing
Select(transform) withWhere(filter) is the most common beginner mistake.Selectalways produces the same count as the source;Wheremay produce fewer.Projecting to anonymous types
Select(x => new { x.Symbol, x.Close })creates an anonymous type inferred by the compiler. Use named record or class types when the projection must cross method boundaries or be returned from a method.
Where
Filters elements by a predicate:
data.Where(x => x.Age > 30). Returns anIEnumerable<T>containing only elements for which the predicate returnstrue. Equivalent to SQLWHEREor Pythonfilter().Use
First(predicate)orSingle(predicate)when you expect exactly one result — usingWherewhen you need a single element forces a second traversal or requires.First()chained afterWhere.
FirstvsFirstOrDefaulton empty sequences
First(predicate)throwsInvalidOperationExceptionwhen no element matches. UseFirstOrDefault(predicate)and null-check the result when an empty match is a valid outcome.
GroupBy
Groups elements by a key selector function:
data.GroupBy(x => x.Department). ReturnsIEnumerable<IGrouping<TKey, TElement>>— oneIGroupingper distinct key. Access.Keyfor the group identifier and enumerate the group itself for its elements.Equivalent to SQL
GROUP BYor Pythonitertools.groupby(but does not require the source to be pre-sorted). Use withSelect(g => new { g.Key, ... })to project aggregates per group.IGrouping is lazy too
Each
IGrouping<TKey, TElement>is itself a deferred sequence. Calling aggregates like.Count(),.Sum(), or.Average()inside aSelectafterGroupByenumerates the group on each call. If multiple aggregates are needed on the same group, materializing each group with.ToList()inside theSelectavoids repeated enumeration.
Aggregate
Applies an accumulator function sequentially across a sequence, threading the result from one step to the next:
data.Aggregate((acc, x) => acc + x). Equivalent to Python’sfunctools.reduce(). An optional seed value initializes the accumulator before the first element.Without a seed,
AggregatethrowsInvalidOperationExceptionon an empty sequence. With a seed (data.Aggregate(0, (acc, x) => acc + x)), an empty sequence safely returns the seed value.Seed-less Aggregate throws on empty sequences
The overload
Aggregate(func)uses the first element as the implicit seed. If the sequence is empty, it throws. Always provide an explicit seed unless the source is guaranteed non-empty.
Polars.NET
A .NET binding for the Polars DataFrame library — a Rust-backed, columnar, multi-threaded analytical engine. Exposes a
DataFrame/SeriesAPI in C# for vectorized operations over large datasets, reading Parquet files directly without ORM overhead.Newer ecosystem with fewer community examples than pandas (Python) or LINQ on collections. The expression API (
Col(...),Lit(...),.Over(...)) mirrors Polars’ lazy evaluation model and supports window functions natively via.Shift,.CumSum,.RollingMean, and.Rank.LINQ vs Polars.NET — when to choose each
- LINQ: natural choice for in-memory object graphs loaded from Dapper, EF Core, or any
IEnumerable<T>. Zero extra dependencies; composes with the type system.- Polars.NET: better for large columnar datasets (>100 K rows), Parquet ingestion, or when vectorized aggregations and window functions need to run fast without writing LINQ workarounds.
Generics let you write type-safe code that works across multiple types without duplication — the compiler enforces correctness at compile time rather than deferring to runtime casts. LINQ (Language Integrated Query) extends this with a declarative pipeline model for filtering, transforming, grouping, and joining collections directly in C#, mirroring SQL semantics while preserving strong typing.
// Suppress CS1701/CS1702 assembly version warnings in .NET Interactive.
using System.Reflection;
using Microsoft.DotNet.Interactive;
using Microsoft.DotNet.Interactive.CSharp;
var csharpKernel = (CSharpKernel)Kernel.Root.FindKernelByName("csharp");
var optionsField = typeof(CSharpKernel).GetField("_scriptOptions",
BindingFlags.NonPublic | BindingFlags.Instance);
var scriptOptions = optionsField.GetValue(csharpKernel);
var withWarningLevel = scriptOptions.GetType().GetMethod("WithWarningLevel");
var newOptions = withWarningLevel.Invoke(scriptOptions, new object[] { 0 });
optionsField.SetValue(csharpKernel, newOptions);#r "nuget: Microsoft.Data.SqlClient"
#r "nuget: Dapper"
#r "nuget: Polars.NET"
#r "nuget: Polars.NET.Native.win-x64"
using Microsoft.Data.SqlClient;
using Dapper;
using Polars.CSharp;
using static Polars.CSharp.Polars;
using Microsoft.DotNet.Interactive.Formatting;
// Register Polars DataFrame/Series HTML formatters (transparent for dark theme)
Formatter.Register<DataFrame>((df, writer) =>
{
var html = df.ToHtml();
html = System.Text.RegularExpressions.Regex.Replace(html, @"(>|>)"(.+?)"(<|<)", @"$1$2$3");
html = System.Text.RegularExpressions.Regex.Replace(html, @">""(.+?)""<", @">$1<");
var css = @"<style>.pl-dataframe,.pl-dataframe *{background:transparent!important;background-color:transparent!important;color:var(--vscode-editor-foreground,inherit)!important}.pl-dataframe{font-size:14px!important;border-collapse:collapse;width:auto}.pl-dataframe td,.pl-dataframe th{padding:6px 12px!important;text-align:left;border:1px solid var(--vscode-panel-border,#555)!important}.pl-dataframe th{font-weight:bold}.pl-dataframe .pl-dtype{font-size:11px;opacity:0.5}</style>";
writer.Write(css + html);
}, "text/html");
Formatter.Register<Polars.CSharp.Series>((s, writer) =>
{
var sdf = DataFrame.FromSeries(s);
var shtml = sdf.ToHtml();
shtml = System.Text.RegularExpressions.Regex.Replace(shtml, @"(>|>)"(.+?)"(<|<)", @"$1$2$3");
shtml = System.Text.RegularExpressions.Regex.Replace(shtml, @">""(.+?)""<", @">$1<");
var scss = @"<style>.pl-dataframe,.pl-dataframe *{background:transparent!important;color:var(--vscode-editor-foreground,inherit)!important}.pl-dataframe{font-size:14px!important;border-collapse:collapse}.pl-dataframe td,.pl-dataframe th{padding:6px 12px!important;text-align:left;border:1px solid var(--vscode-panel-border,#555)!important}.pl-dataframe th{font-weight:bold}.pl-dataframe .pl-dtype{font-size:11px;opacity:0.5}</style>";
writer.Write(scss + shtml);
}, "text/html");Generics
Generics enable writing reusable, type-safe code by parameterizing classes, methods, and interfaces with type placeholders (T, TKey, TValue). The compiler substitutes concrete types at compile time, eliminating boxing for value types and catching type mismatches before runtime. Constraints (where T : ...) restrict what types are valid, unlocking access to interface methods, constructors, and base class members within the generic body.
C# | Generics | type parameters and constraints
Generic type parameters are placeholders declared in angle brackets. The compiler infers T from arguments when possible, or you can specify it explicitly. Constraints narrow the allowed types, enabling the compiler to guarantee that operations like .CompareTo() or new T() are valid.
Generic method
Generic methods
T First<T>(T[] items)— declares a type parameterTthe compiler infers from the argument- One method handles
int[],string[],double[]— no overloads needed- Type safety preserved at compile time
- Avoid
objectinstead of generics (loses type safety, requires casting)
T First<T>(T[] items) => items[0];
Console.WriteLine(First(new[] { 1, 2, 3 })); // int
Console.WriteLine(First(new[] { "a", "b", "c" })); // string
Console.WriteLine(First(new[] { 1.1, 2.2, 3.3 })); // double
Console.WriteLine(First<string>(new[] { "x", "y" })); // explicit type argument1
a
1.1
xGeneric constraints — where T : ...
Generic constraints restrict what types can be used as a type parameter. Without constraints, T could be anything — you can’t call methods on it because the compiler doesn’t know what T is. Adding where T : IComparable guarantees that T has a CompareTo method, enabling type-safe operations. Common constraints: class (reference type), struct (value type), new() (has parameterless constructor), notnull, and interface/base class requirements.
T Max<T>(T a, T b) where T : IComparable<T>
=> a.CompareTo(b) >= 0 ? a : b;
Console.WriteLine(Max(3, 7)); // 7
Console.WriteLine(Max("apple", "banana")); // banana
// Max(new object(), new object()); // Compile error — object doesn't implement IComparable7
bananaCommon generic constraints
Constraint Meaning where T : structT must be a value type ( int,bool, customstruct)where T : classT must be a reference type ( string,class)where T : new()T must have a parameterless constructor (must appear last) where T : IComparable<T>T must implement the specified interface where T : BaseClassT must inherit from a specific class where T : notnullT cannot be nullwhere T : unmanagedT must be an unmanaged type (no reference-type fields) where T : UT must be or derive from another type parameter U where T : defaultResolves ambiguity when overriding unconstrained methods where T : allows ref structT may be a ref struct(anti-constraint, C# 13+)
where T : class—==tests reference identity, not value equalityWhen using the
classconstraint, the==and!=operators onTcompare reference identity, not value equality — even if the concrete type (e.g.,string) overloads==. Two distinctstringinstances with the same content will compare asfalse.
Use
IEquatable<T>for value comparisonAdd
where T : IEquatable<T>and calla.Equals(b)instead ofa == bwhen you need value equality semantics inside a generic method or class.
Generic class and multiple type parameters
A generic class is parameterized by one or more types, allowing the same data structure to work with any type while maintaining compile-time type safety. Result<TValue, TError> can represent a success value OR an error without boxing or casting. Multiple type parameters let you build type-safe pairs, key-value mappings, and response wrappers.
var ints = new List<int> { 1, 2, 3 };
var lookup = new Dictionary<string, int> { ["Alice"] = 85, ["Bob"] = 92 };
Console.WriteLine($"[{string.Join(", ", ints)}]");
Console.WriteLine(string.Join(", ", lookup.Select(kv => $"{kv.Key}: {kv.Value}")));
(TKey, TValue) MakePair<TKey, TValue>(TKey key, TValue value) => (key, value);
var pair1 = MakePair("name", 42);
var pair2 = MakePair(1, true);
Console.WriteLine(pair1);
Console.WriteLine(pair2);[1, 2, 3]
92
(name, 42)
(1, True)Advanced LINQ
LINQ extends C# with a declarative query model over any IEnumerable<T>. Method syntax chains — Where, Select, GroupBy, Join, SelectMany — compose into lazy pipelines that execute only when enumerated. This section demonstrates core LINQ operators using in-memory anonymous-type collections, covering grouping with aggregations, inner and left joins, lookups, zipping parallel sequences, and flattening nested collections.
C# | LINQ | core operators
The examples below use a shared dataset of employees and departments defined as anonymous types. Each operator is shown independently so the pipeline logic is clear.
Sample data
Shared in-memory collections used throughout the LINQ section — six employees across three departments and four department records (including one with no employees, to demonstrate left join behavior).
var employees = new[]
{
new { Name = "Alice", Dept = "Engineering", Salary = 95000, Level = "senior" },
new { Name = "Bob", Dept = "Sales", Salary = 65000, Level = "junior" },
new { Name = "Charlie", Dept = "Engineering", Salary = 110000, Level = "lead" },
new { Name = "Diana", Dept = "Sales", Salary = 78000, Level = "senior" },
new { Name = "Eve", Dept = "Engineering", Salary = 88000, Level = "junior" },
new { Name = "Frank", Dept = "Marketing", Salary = 72000, Level = "senior" },
};
var departments = new[]
{
new { Dept = "Engineering", Budget = 500000, Head = "CTO" },
new { Dept = "Sales", Budget = 300000, Head = "VP Sales" },
new { Dept = "Marketing", Budget = 200000, Head = "CMO" },
new { Dept = "HR", Budget = 150000, Head = "CHRO" }, // no employees
};GroupBy and aggregations
GroupBy partitions a sequence into groups based on a key function, then lets you aggregate each group independently. It’s the LINQ equivalent of SQL’s GROUP BY — you specify what to group by (e.g., sector), then compute aggregates per group (count, sum, average). The result is an IGrouping<TKey, TElement> for each distinct key.
var byDept = employees.GroupBy(e => e.Dept);
foreach (var group in byDept)
{
var names = string.Join(", ", group.Select(e => e.Name));
var avgSalary = group.Average(e => e.Salary);
Console.WriteLine($" {group.Key,-15} ({group.Count()} people): [{names}] avg=${avgSalary:N0}");
}
foreach (var group in byDept)
{
var top = group.MaxBy(e => e.Salary)!;
Console.WriteLine($" {group.Key,-15} top earner: {top.Name} ${top.Salary:N0}");
}
var deptStats = employees.GroupBy(e => e.Dept).Select(g => new
{
Dept = g.Key,
Count = g.Count(),
AvgSalary = g.Average(e => e.Salary),
MaxSalary = g.Max(e => e.Salary),
MinSalary = g.Min(e => e.Salary),
TotalSalary = g.Sum(e => e.Salary),
});
foreach (var s in deptStats)
Console.WriteLine($" {s.Dept,-15} count={s.Count} avg=${s.AvgSalary:N0} range=[${s.MinSalary:N0}-${s.MaxSalary:N0}] total=${s.TotalSalary:N0}");Engineering (3 people): [Alice, Charlie, Eve] avg=$97'667
Sales (2 people): [Bob, Diana] avg=$71'500
Marketing (1 people): [Frank] avg=$72'000
Engineering top earner: Charlie $110'000
Sales top earner: Diana $78'000
Marketing top earner: Frank $72'000
Engineering count=3 avg=$97'667 range=[$88'000-$110'000] total=$293'000
Sales count=2 avg=$71'500 range=[$65'000-$78'000] total=$143'000
Marketing count=1 avg=$72'000 range=[$72'000-$72'000] total=$72'000Join and GroupJoin
Join combines two sequences by matching a key from each — the LINQ equivalent of SQL’s INNER JOIN. GroupJoin is a LEFT JOIN variant that groups all matching right-side elements under each left-side element, producing a hierarchical result. Both require you to specify the outer key, inner key, and result selector.
Join requires matching key types
The outer and inner key selectors must return the same type. If one returns
intand the other returnsstring, the join silently produces zero results with no compile-time error. Always verify key types match.
Verify key types before joining
Confirm that both key selectors return the same type (e.g., both
string). Use explicit casts or.ToString()if types differ, and add a unit test that asserts the join result count is greater than zero.
var innerJoin = employees.Join(
departments,
e => e.Dept,
d => d.Dept,
(e, d) => new { e.Name, e.Dept, d.Head, d.Budget }
);
foreach (var r in innerJoin.Take(3))
Console.WriteLine($" {r.Name,-10} {r.Dept,-15} head={r.Head,-10} budget=${r.Budget:N0}");
var leftJoin = departments.GroupJoin(
employees,
d => d.Dept,
e => e.Dept,
(d, emps) => new { d.Dept, d.Head, Count = emps.Count() }
);
foreach (var r in leftJoin)
Console.WriteLine($" {r.Dept,-15} head={r.Head,-10} employees={r.Count}");Alice Engineering head=CTO budget=$500'000
Bob Sales head=VP Sales budget=$300'000
Charlie Engineering head=CTO budget=$500'000
Engineering head=CTO employees=3
Sales head=VP Sales employees=2
Marketing head=CMO employees=1
HR head=CHRO employees=0Chained pipeline, Lookup, and Zip
LINQ pipelines compose by chaining operators — Where → Select → OrderByDescending → Take reads left to right as filter, project, sort, limit. ToLookup creates a dictionary-like structure that allows multiple values per key (unlike ToDictionary which throws on duplicates). Zip pairs elements from two or three sequences positionally, stopping at the shortest.
var result = employees
.Where(e => e.Salary > 75000)
.Select(e => new { e.Name, e.Salary, Tax = e.Salary * 0.3 })
.OrderByDescending(e => e.Salary)
.Take(3);
foreach (var r in result)
Console.WriteLine($" {r.Name,-10} salary=${r.Salary:N0} tax=${r.Tax:N0}");
var empLookup = employees.ToLookup(e => e.Dept);
Console.WriteLine($"Engineering: [{string.Join(", ", empLookup["Engineering"].Select(e => e.Name))}]");
Console.WriteLine($"Unknown: [{string.Join(", ", empLookup["Unknown"].Select(e => e.Name))}]");
var names = employees.Select(e => e.Name);
var salaries = employees.Select(e => e.Salary);
var raises = employees.Select(e => e.Salary * 0.1);
foreach (var (name, salary, raise_amt) in names.Zip(salaries, raises))
Console.WriteLine($" {name,-10} ${salary,8:N0} + ${raise_amt,7:N0} raise");Charlie salary=$110'000 tax=$33'000
Alice salary=$95'000 tax=$28'500
Eve salary=$88'000 tax=$26'400
Engineering: [Alice, Charlie, Eve]
Unknown: []
Alice $ 95'000 + $ 9'500 raise
Bob $ 65'000 + $ 6'500 raise
Charlie $ 110'000 + $ 11'000 raise
Diana $ 78'000 + $ 7'800 raise
Eve $ 88'000 + $ 8'800 raise
Frank $ 72'000 + $ 7'200 raiseSelectMany — flatten nested collections
SelectMany projects each element to a collection, then flattens all those collections into one sequence. It’s the LINQ equivalent of a nested loop or SQL’s CROSS APPLY. Common use: a list of orders where each order has multiple line items — SelectMany gives you a flat list of all line items across all orders.
var people = new[]
{
new { Name = "Alice", Skills = new[] { "C#", "LINQ", "SQL" } },
new { Name = "Bob", Skills = new[] { "Python", "SQL" } },
new { Name = "Charlie", Skills = new[] { "C#", "Go" } },
};
foreach (var arr in people.Select(p => p.Skills))
Console.WriteLine($" [{string.Join(", ", arr)}]");
var allSkills = people.SelectMany(p => p.Skills);
Console.WriteLine($"SelectMany (flat): [{string.Join(", ", allSkills)}]");
var pairs = people.SelectMany(
p => p.Skills,
(p, skill) => $"{p.Name}: {skill}"
);
foreach (var pair in pairs)
Console.WriteLine($" {pair}");
Console.WriteLine($"Distinct skills: [{string.Join(", ", people.SelectMany(p => p.Skills).Distinct().OrderBy(s => s))}]");
var matrix = new List<List<int>>
{
new List<int> { 1, 2, 3 },
new List<int> { 4, 5 },
new List<int> { 6, 7, 8, 9 },
};
Console.WriteLine($"Flat matrix: [{string.Join(", ", matrix.SelectMany(row => row))}]");Select (nested):
[C#, LINQ, SQL]
[Python, SQL]
[C#, Go]
SelectMany (flat): [C#, LINQ, SQL, Python, SQL, C#, Go]
SelectMany with result selector:
Alice: C#
Alice: LINQ
Alice: SQL
Bob: Python
Bob: SQL
Charlie: C#
Charlie: Go
Distinct skills: [C#, Go, LINQ, Python, SQL]
Flat matrix: [1, 2, 3, 4, 5, 6, 7, 8, 9]LINQ Analytics on Live SQL Server Data
Advanced analytics queries written in LINQ against the local stoxx database — the C# equivalent
of SQL window functions, running aggregates, and analytical patterns.
Tables used:
silver.eurostoxx50_ohlcv— 66K rows of daily OHLCV data for 50 European stocksgold.scores_daily— composite scores with 36 metrics per stockgold.index_performance— daily index-level returns and rolling metricssilver.index_dim— company metadata (sector, country, exchange)bronze.trading_calendar— 29K trading day flags per exchange
C# | LINQ | SQL Server data setup
Connection and DTO records for loading EUROSTOXX 50 OHLCV and composite score data from the local stoxx database using Dapper.
DTO records for SQL Server data mapping
Dapper maps SQL result columns to C# record properties by matching names. Records must be declared in a separate cell because C# requires type declarations before top-level statements in .NET Interactive.
record Ohlcv(string Symbol, DateTime Date, double Open, double High, double Low,
double Close, double AdjClose, long Volume);
record ScoreRow(string Symbol, string Sector, string Country, double CompositeScore,
short CompositeRank, double MomentumScore, double CurrentPrice, double YtdChangePct);var connStr = "Server=localhost,1434;Database=stoxx;"
+ "User Id=sa;Password=EsgDev2026Pass1;"
+ "Encrypt=True;TrustServerCertificate=True;";
List<Ohlcv> ohlcv;
List<ScoreRow> scores;
using (var conn = new SqlConnection(connStr))
{
conn.Open();
ohlcv = conn.Query<Ohlcv>(
"SELECT symbol AS Symbol, date AS Date, [open] AS [Open], high AS High, low AS Low, "
+ "[close] AS [Close], adj_close AS AdjClose, volume AS Volume "
+ "FROM silver.eurostoxx50_ohlcv").AsList();
scores = conn.Query<ScoreRow>(
"SELECT symbol AS Symbol, sector AS Sector, country AS Country, "
+ "composite_score AS CompositeScore, composite_rank AS CompositeRank, "
+ "momentum_score AS MomentumScore, current_price AS CurrentPrice, "
+ "ytd_change_pct AS YtdChangePct "
+ "FROM gold.scores_daily").AsList();
}
Console.WriteLine($" OHLCV: {ohlcv.Count:N0} rows, {ohlcv.Select(r => r.Symbol).Distinct().Count()} symbols");
Console.WriteLine($" Scores: {scores.Count:N0} rows");
Console.WriteLine($" Date range: {ohlcv.Min(r => r.Date):yyyy-MM-dd} to {ohlcv.Max(r => r.Date):yyyy-MM-dd}");66'355 rows, 50 symbols
466 rows
2021-01-04 to 2026-03-12C# | LINQ | analytical queries on financial data
Each query below demonstrates a LINQ pattern equivalent to a common SQL analytical operation — aggregation, window functions, filtering after grouping, and joins — applied to real EUROSTOXX 50 market data.
LINQ — GroupBy with Aggregates
Splits a collection into groups by key and applies multiple aggregate functions (Average, Sum, Min, Max, Count) to each group.
var summary = ohlcv
.GroupBy(r => r.Symbol)
.Select(g => new
{
Symbol = g.Key,
AvgClose = g.Average(r => r.Close),
TotalVolume = g.Sum(r => r.Volume),
MinLow = g.Min(r => r.Low),
MaxHigh = g.Max(r => r.High),
Days = g.Count(),
})
.OrderByDescending(s => s.AvgClose)
.Take(10);
// Display as Polars DataFrame
var results = summary.ToList();
new DataFrame(
Series.From("Symbol", results.Select(s => s.Symbol).ToArray()),
Series.From("AvgClose", results.Select(s => Math.Round(s.AvgClose, 2)).ToArray()),
Series.From("TotalVolume", results.Select(s => s.TotalVolume).ToArray()),
Series.From("MinLow", results.Select(s => Math.Round(s.MinLow, 2)).ToArray()),
Series.From("MaxHigh", results.Select(s => Math.Round(s.MaxHigh, 2)).ToArray()),
Series.From("Days", results.Select(s => s.Days).ToArray()))| Symbol | AvgClose | TotalVolume | MinLow | MaxHigh | Days |
|---|---|---|---|---|---|
| RMS.PA | 1761.56 | 81633862 | 839.4 | 2957 | 1331 |
| ADYEN.AS | 1545.98 | 110400463 | 602.8 | 2835 | 1331 |
| ASML.AS | 671.35 | 945070720 | 375.75 | 1312.8 | 1331 |
| MC.PA | 662.4 | 557855567 | 436.55 | 904.6 | 1331 |
| RHM.DE | 544.66 | 308359744 | 76.28 | 2008 | 1324 |
| ARGX.BR | 413.69 | 94592244 | 201.4 | 810 | 1331 |
| OR.PA | 377.54 | 484115375 | 290.1 | 461.85 | 1331 |
| MUV2.DE | 374.66 | 398802950 | 205.15 | 615.8 | 1324 |
| RACE.MI | 289.75 | 476686026 | 154.4 | 492.8 | 1321 |
| ALV.DE | 252.19 | 1101960308 | 156.22 | 396 | 1324 |
LINQ — Window Function ROW_NUMBER()
Assigns a sequential rank to each row within a partition, ordered by a column. Equivalent to SQL ROW_NUMBER() OVER (PARTITION BY … ORDER BY …).
var topVolumeDay = ohlcv
.GroupBy(r => r.Symbol)
.SelectMany(g => g
.OrderByDescending(r => r.Volume)
.Select((r, idx) => new { r.Symbol, r.Date, r.Close, r.Volume, Rank = idx + 1 })
.Where(r => r.Rank == 1))
.OrderByDescending(r => r.Volume)
.Take(10);
var results = topVolumeDay.ToList();
new DataFrame(
Series.From("Symbol", results.Select(r => r.Symbol).ToArray()),
Series.From("Date", results.Select(r => r.Date.ToString("yyyy-MM-dd")).ToArray()),
Series.From("Close", results.Select(r => Math.Round(r.Close, 2)).ToArray()),
Series.From("Volume", results.Select(r => r.Volume).ToArray()))| Symbol | Date | Close | Volume |
|---|---|---|---|
| ISP.MI | 2023-08-08 | 2.34 | 376391539 |
| SAN.MC | 2021-10-20 | 3.36 | 367211467 |
| BBVA.MC | 2021-09-17 | 5.67 | 228528294 |
| NDA-FI.HE | 2022-09-16 | 9.14 | 140675854 |
| PRX.AS | 2021-08-17 | 32.43 | 114772834 |
| ENEL.MI | 2021-10-15 | 6.92 | 101413521 |
| UCG.MI | 2021-12-09 | 12.8 | 82881371 |
| IBE.MC | 2022-10-21 | 9.53 | 82592287 |
| INGA.AS | 2024-02-01 | 12.34 | 55872649 |
| ENI.MI | 2025-04-07 | 12.04 | 48554374 |
LINQ — Window Function LAG()
Accesses the value from the previous row in a sorted sequence. Implemented via Zip with a shifted copy of the list.
var withReturns = ohlcv
.GroupBy(r => r.Symbol)
.SelectMany(g =>
{
var sorted = g.OrderBy(r => r.Date).ToList();
return sorted.Skip(1).Zip(sorted, (curr, prev) => new
{
curr.Symbol, curr.Date, curr.Close,
PrevClose = prev.Close,
DailyReturn = (curr.Close - prev.Close) / prev.Close * 100,
});
});
var topGains = withReturns.OrderByDescending(r => r.DailyReturn).Take(10);
var results = topGains.ToList();
new DataFrame(
Series.From("Symbol", results.Select(r => r.Symbol).ToArray()),
Series.From("Date", results.Select(r => r.Date.ToString("yyyy-MM-dd")).ToArray()),
Series.From("Close", results.Select(r => Math.Round(r.Close, 2)).ToArray()),
Series.From("PrevClose", results.Select(r => Math.Round(r.PrevClose, 2)).ToArray()),
Series.From("Return%", results.Select(r => Math.Round(r.DailyReturn, 2)).ToArray()))| Symbol | Date | Close | PrevClose | Return% |
|---|---|---|---|---|
| ADYEN.AS | 2023-11-09 | 958.8 | 695.7 | 37.82 |
| ARGX.BR | 2023-07-17 | 437.6 | 334 | 31.02 |
| RHM.DE | 2022-02-28 | 133.6 | 107.05 | 24.8 |
| PRX.AS | 2022-03-16 | 24.1 | 19.45 | 23.88 |
| ADS.DE | 2022-11-04 | 114.04 | 93.95 | 21.38 |
| ADYEN.AS | 2024-02-08 | 1436.2 | 1183.6 | 21.34 |
| ENR.DE | 2024-11-13 | 46.33 | 38.95 | 18.95 |
| RHM.DE | 2022-03-01 | 156.6 | 133.6 | 17.22 |
| VOW.DE | 2021-03-17 | 308.8 | 266.6 | 15.83 |
| PRX.AS | 2022-06-27 | 28.17 | 24.35 | 15.72 |
LINQ — Window Function Cumulative SUM()
Computes a running total where each row includes the sum of all preceding rows. Implemented via Aggregate with an accumulator.
var cumVol = ohlcv
.Where(r => r.Symbol == "ASML.AS")
.OrderBy(r => r.Date)
.Aggregate(
new List<(DateTime Date, long Vol, long CumVol)>(),
(acc, r) => { acc.Add((r.Date, r.Volume, (acc.Count > 0 ? acc[^1].CumVol : 0) + r.Volume)); return acc; });
new DataFrame(
Series.From("Date", cumVol.TakeLast(10).Select(r => r.Date.ToString("yyyy-MM-dd")).ToArray()),
Series.From("Volume", cumVol.TakeLast(10).Select(r => r.Vol).ToArray()),
Series.From("CumVolume", cumVol.TakeLast(10).Select(r => r.CumVol).ToArray()))| Date | Volume | CumVolume |
|---|---|---|
| 2026-02-27 | 1010698 | 938726541 |
| 2026-03-02 | 871267 | 939597808 |
| 2026-03-03 | 941945 | 940539753 |
| 2026-03-04 | 714587 | 941254340 |
| 2026-03-05 | 778081 | 942032421 |
| 2026-03-06 | 857271 | 942889692 |
| 2026-03-09 | 689086 | 943578778 |
| 2026-03-10 | 800815 | 944379593 |
| 2026-03-11 | 562904 | 944942497 |
| 2026-03-12 | 128223 | 945070720 |
LINQ — Window Function AVG() Moving Average
Computes the average of a sliding window of N rows. Implemented via Skip/Take on a sorted list for each position.
var W = 20;
var asml = ohlcv.Where(r => r.Symbol == "ASML.AS").OrderBy(r => r.Date).ToList();
var sma = Enumerable.Range(W - 1, asml.Count - W + 1)
.Select(i => new { asml[i].Date, asml[i].Close,
SMA20 = asml.Skip(i - W + 1).Take(W).Average(r => r.Close) });
var results = sma.TakeLast(10).ToList();
new DataFrame(
Series.From("Date", results.Select(r => r.Date.ToString("yyyy-MM-dd")).ToArray()),
Series.From("Close", results.Select(r => Math.Round(r.Close, 2)).ToArray()),
Series.From("SMA20", results.Select(r => Math.Round(r.SMA20, 2)).ToArray()))| Date | Close | SMA20 |
|---|---|---|
| 2026-02-27 | 1233.4 | 1213.73 |
| 2026-03-02 | 1210.4 | 1213.01 |
| 2026-03-03 | 1161.8 | 1211.58 |
| 2026-03-04 | 1199.8 | 1214.54 |
| 2026-03-05 | 1186 | 1216.36 |
| 2026-03-06 | 1147 | 1214.02 |
| 2026-03-09 | 1147.6 | 1211.16 |
| 2026-03-10 | 1200 | 1211.51 |
| 2026-03-11 | 1198.8 | 1211.06 |
| 2026-03-12 | 1190.8 | 1211.61 |
LINQ — Window Function NTILE()
Distributes rows into N equal-sized buckets based on a sort order. Implemented via index arithmetic after OrderBy.
var symbolCount = ohlcv.Select(r => r.Symbol).Distinct().Count();
var quartiles = ohlcv
.GroupBy(r => r.Symbol)
.Select(g => new { Symbol = g.Key, AvgClose = g.Average(r => r.Close) })
.OrderBy(s => s.AvgClose)
.Select((s, idx) => new { s.Symbol, s.AvgClose, Quartile = idx * 4 / symbolCount + 1 })
.OrderByDescending(s => s.AvgClose);
var results = quartiles.Take(10).ToList();
new DataFrame(
Series.From("Symbol", results.Select(s => s.Symbol).ToArray()),
Series.From("AvgClose", results.Select(s => Math.Round(s.AvgClose, 2)).ToArray()),
Series.From("Quartile", results.Select(s => s.Quartile).ToArray()))| Symbol | AvgClose | Quartile |
|---|---|---|
| RMS.PA | 1761.56 | 4 |
| ADYEN.AS | 1545.98 | 4 |
| ASML.AS | 671.35 | 4 |
| MC.PA | 662.4 | 4 |
| RHM.DE | 544.66 | 4 |
| ARGX.BR | 413.69 | 4 |
| OR.PA | 377.54 | 4 |
| MUV2.DE | 374.66 | 4 |
| RACE.MI | 289.75 | 4 |
| ALV.DE | 252.19 | 4 |
LINQ — HAVING
Filters groups after aggregation. A Where clause applied after GroupBy + Select acts as the SQL HAVING clause.
var highVol = ohlcv
.GroupBy(r => r.Symbol)
.Select(g => new { Symbol = g.Key, AvgVol = g.Average(r => (double)r.Volume) })
.Where(s => s.AvgVol > 5_000_000)
.OrderByDescending(s => s.AvgVol);
var results = highVol.ToList();
new DataFrame(
Series.From("Symbol", results.Select(s => s.Symbol).ToArray()),
Series.From("AvgVolume", results.Select(s => (long)s.AvgVol).ToArray()))| Symbol | AvgVolume |
|---|---|
| ISP.MI | 87588601 |
| SAN.MC | 41770987 |
| ENEL.MI | 24678699 |
| BBVA.MC | 16654456 |
| UCG.MI | 13903710 |
| ENI.MI | 12976208 |
| INGA.AS | 12803589 |
| IBE.MC | 12034835 |
| DTE.DE | 7575084 |
| NDA-FI.HE | 5375454 |
| ... 1 more rows ... | |
LINQ — STDEV()
Standard deviation of daily returns, annualized by multiplying by sqrt(252). No built-in LINQ StdDev — computed manually.
double StdDev(IEnumerable<double> v)
{
var l = v.ToList(); var a = l.Average();
return Math.Sqrt(l.Sum(x => (x - a) * (x - a)) / (l.Count - 1));
}
var vol = ohlcv.GroupBy(r => r.Symbol).Select(g =>
{
var s = g.OrderBy(r => r.Date).ToList();
var ret = s.Skip(1).Zip(s, (c, p) => (c.Close - p.Close) / p.Close).ToList();
return new { Symbol = g.Key, AnnVol = StdDev(ret) * Math.Sqrt(252) * 100 };
}).OrderByDescending(s => s.AnnVol).Take(10);
var results = vol.ToList();
new DataFrame(
Series.From("Symbol", results.Select(s => s.Symbol).ToArray()),
Series.From("AnnualVol%", results.Select(s => Math.Round(s.AnnVol, 2)).ToArray()))| Symbol | AnnualVol% |
|---|---|
| ADYEN.AS | 50.3 |
| ENR.DE | 50.05 |
| RHM.DE | 40.85 |
| PRX.AS | 39.72 |
| ARGX.BR | 39.31 |
| ASML.AS | 37.62 |
| IFX.DE | 37.25 |
| UCG.MI | 35.55 |
| VOW.DE | 35.51 |
| ADS.DE | 34.37 |
LINQ — JOIN
Combines two collections on a matching key. Each OHLCV aggregate row is paired with its corresponding score row by symbol.
var joined = ohlcv
.GroupBy(r => r.Symbol)
.Select(g => new { Symbol = g.Key, AvgClose = g.Average(r => r.Close), AvgVol = g.Average(r => (double)r.Volume) })
.Join(scores, o => o.Symbol, s => s.Symbol,
(o, s) => new { o.Symbol, s.Sector, o.AvgClose, o.AvgVol, s.CompositeScore, s.CompositeRank, s.YtdChangePct })
.OrderBy(r => r.CompositeRank)
.Take(10);
var results = joined.ToList();
new DataFrame(
Series.From("Symbol", results.Select(r => r.Symbol).ToArray()),
Series.From("Sector", results.Select(r => r.Sector).ToArray()),
Series.From("Rank", results.Select(r => (int)r.CompositeRank).ToArray()),
Series.From("Score", results.Select(r => Math.Round(r.CompositeScore, 2)).ToArray()),
Series.From("YTD%", results.Select(r => Math.Round(r.YtdChangePct, 1)).ToArray()),
Series.From("AvgVol", results.Select(r => (long)r.AvgVol).ToArray()))| Symbol | Sector | Rank | Score | YTD% | AvgVol |
|---|---|---|---|---|---|
| BNP.PA | Financial Services | 1 | 0.68 | 0.1 | 3096879 |
| BNP.PA | Financial Services | 1 | 0.66 | 0.1 | 3096879 |
| BNP.PA | Financial Services | 1 | 0.68 | 0.1 | 3096879 |
| DTE.DE | Communication Services | 2 | 0.52 | 0.2 | 7575084 |
| DTE.DE | Communication Services | 2 | 0.52 | 0.2 | 7575084 |
| VOW.DE | Consumer Cyclical | 2 | 0.58 | -0.1 | 62021 |
| DTE.DE | Communication Services | 3 | 0.49 | 0.2 | 7575084 |
| IFX.DE | Technology | 3 | 0.51 | 0.2 | 4186778 |
| VOW.DE | Consumer Cyclical | 3 | 0.46 | -0.1 | 62021 |
| TTE.PA | Energy | 4 | 0.39 | 0.3 | 5138099 |
LINQ — Window Function LEAD()
Accesses the value from the next row in a sorted sequence. Implemented via Zip with a Skip(1) shifted copy. Used here to detect date gaps.
var gaps = ohlcv
.Where(r => r.Symbol == "ASML.AS")
.OrderBy(r => r.Date)
.Zip(ohlcv.Where(r => r.Symbol == "ASML.AS").OrderBy(r => r.Date).Skip(1),
(curr, next) => new { FromDate = curr.Date, ToDate = next.Date, GapDays = (next.Date - curr.Date).Days })
.Where(g => g.GapDays > 3)
.OrderByDescending(g => g.GapDays)
.Take(10);
var results = gaps.ToList();
new DataFrame(
Series.From("From", results.Select(g => g.FromDate.ToString("yyyy-MM-dd")).ToArray()),
Series.From("To", results.Select(g => g.ToDate.ToString("yyyy-MM-dd")).ToArray()),
Series.From("GapDays", results.Select(g => g.GapDays).ToArray()))| From | To | GapDays |
|---|---|---|
| 2021-04-01 | 2021-04-06 | 5 |
| 2022-04-14 | 2022-04-19 | 5 |
| 2023-04-06 | 2023-04-11 | 5 |
| 2023-12-22 | 2023-12-27 | 5 |
| 2024-03-28 | 2024-04-02 | 5 |
| 2025-04-17 | 2025-04-22 | 5 |
| 2025-12-24 | 2025-12-29 | 5 |
| 2022-12-23 | 2022-12-27 | 4 |
| 2023-04-28 | 2023-05-02 | 4 |
| 2023-12-29 | 2024-01-02 | 4 |
LINQ — Nested GroupBy
Groups by a key and computes nested aggregates including the best element per group via OrderBy + First().
var sectorSummary = scores
.GroupBy(s => s.Sector)
.Select(g => new
{
Sector = g.Key,
AvgScore = g.Average(s => s.CompositeScore),
BestStock = g.OrderBy(s => s.CompositeRank).First().Symbol,
BestRank = g.Min(s => s.CompositeRank),
Count = g.Count(),
})
.OrderByDescending(s => s.AvgScore);
var results = sectorSummary.ToList();
new DataFrame(
Series.From("Sector", results.Select(s => s.Sector).ToArray()),
Series.From("AvgScore", results.Select(s => Math.Round(s.AvgScore, 2)).ToArray()),
Series.From("BestStock", results.Select(s => s.BestStock).ToArray()),
Series.From("BestRank", results.Select(s => (int)s.BestRank).ToArray()),
Series.From("Count", results.Select(s => s.Count).ToArray()))| Sector | AvgScore | BestStock | BestRank | Count |
|---|---|---|---|---|
| Technology | 0.15 | MU | 1 | 75 |
| Energy | 0.11 | DVN | 1 | 34 |
| Industrials | 0.09 | 8001.T | 1 | 66 |
| Communication Services | 0.05 | DTE.DE | 2 | 36 |
| Basic Materials | 0.04 | 4063.T | 7 | 18 |
| Healthcare | 0.02 | 2269.HK | 5 | 45 |
| Consumer Defensive | -0.08 | ABI.BR | 4 | 36 |
| Financial Services | -0.09 | BNP.PA | 1 | 96 |
| Utilities | -0.1 | ENEL.MI | 25 | 6 |
| Consumer Cyclical | -0.14 | VOW.DE | 2 | 54 |
LINQ vs Polars.NET — Side-by-Side
Every operation shown first in LINQ (C# collections), then in Polars.NET (Rust DataFrame engine). Both operate on the same OHLCV data loaded from SQL Server.
Load data into Polars DataFrame
Polars.NET reads Parquet files directly into a columnar DataFrame backed by the Rust Polars engine — same data as the Dapper-loaded OHLCV list, but in a format optimized for vectorized operations.
var df = DataFrame.ReadParquet(@"C:\Users\aperi\DEV\LANG\data\eurostoxx50_ohlcv.parquet");
Console.WriteLine($" Polars: {df.Height} rows x {df.Width} columns");
df.Head(3)66355 rows x 12 columns| id | symbol | date | open | high | low | close | adj_close | volume | dividends | stock_splits | is_filled |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 21160 | ABI.BR | 2021-01-04 | 58.15 | 58.85 | 56.78 | 57.21 | 53.5761 | 1513937 | 0 | 0 | false |
| 21161 | ABI.BR | 2021-01-05 | 56.9 | 57.98 | 56.75 | 57.18 | 53.548 | 1382722 | 0 | 0 | false |
| 21162 | ABI.BR | 2021-01-06 | 57.96 | 58.94 | 57.39 | 58.77 | 55.037 | 1370204 | 0 | 0 | false |
Basic Operations
LINQ — Select columns
var linqSelect = ohlcv.Select(r => new { r.Symbol, r.Date, r.Close }).Take(5).ToList();
new DataFrame(
Series.From("Symbol", linqSelect.Select(r => r.Symbol).ToArray()),
Series.From("Date", linqSelect.Select(r => r.Date.ToString("yyyy-MM-dd")).ToArray()),
Series.From("Close", linqSelect.Select(r => r.Close).ToArray()))| Symbol | Date | Close |
|---|---|---|
| ASML.AS | 2021-01-04 | 406.25 |
| ASML.AS | 2021-01-05 | 406.9 |
| ASML.AS | 2021-01-06 | 402.85 |
| ASML.AS | 2021-01-07 | 403.9 |
| ASML.AS | 2021-01-08 | 416.05 |
Polars DataFrame — Select columns
df.Select("symbol", "date", "close").Head(5)| symbol | date | close |
|---|---|---|
| ABI.BR | 2021-01-04 | 57.21 |
| ABI.BR | 2021-01-05 | 57.18 |
| ABI.BR | 2021-01-06 | 58.77 |
| ABI.BR | 2021-01-07 | 58.4 |
| ABI.BR | 2021-01-08 | 57.86 |
LINQ — Filter rows
var linqFilter = ohlcv.Where(r => r.Symbol == "ASML.AS" && r.Close > 600).Take(5).ToList();
new DataFrame(
Series.From("Symbol", linqFilter.Select(r => r.Symbol).ToArray()),
Series.From("Date", linqFilter.Select(r => r.Date.ToString("yyyy-MM-dd")).ToArray()),
Series.From("Close", linqFilter.Select(r => r.Close).ToArray()))| Symbol | Date | Close |
|---|---|---|
| ASML.AS | 2021-07-14 | 609.1 |
| ASML.AS | 2021-07-22 | 620.8 |
| ASML.AS | 2021-07-23 | 638.8 |
| ASML.AS | 2021-07-26 | 638 |
| ASML.AS | 2021-07-27 | 623 |
Polars DataFrame — Filter rows
df.Filter((Col("symbol") == Lit("ASML.AS")) & (Col("close") > Lit(600.0)))
.Select("symbol", "date", "close").Head(5)| id | symbol | date | open | high | low | close | adj_close | volume | dividends | stock_splits | is_filled |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 136 | ASML.AS | 2021-07-14 | 599.5 | 611.8 | 597.2 | 609.1 | 582.9708 | 641585 | 0 | 0 | false |
| 142 | ASML.AS | 2021-07-22 | 610 | 625.9 | 608.2 | 620.8 | 594.169 | 788099 | 0 | 0 | false |
| 143 | ASML.AS | 2021-07-23 | 622.9 | 639 | 617.5 | 638.8 | 611.3967 | 833737 | 0 | 0 | false |
| 144 | ASML.AS | 2021-07-26 | 635.2 | 647 | 631.5 | 638 | 610.631 | 640691 | 0 | 0 | false |
| 145 | ASML.AS | 2021-07-27 | 634.2 | 641.1 | 622.3 | 623 | 596.2745 | 705560 | 0 | 0 | false |
LINQ — Sort
var linqSort = ohlcv.OrderByDescending(r => r.Volume).Take(5).ToList();
new DataFrame(
Series.From("Symbol", linqSort.Select(r => r.Symbol).ToArray()),
Series.From("Date", linqSort.Select(r => r.Date.ToString("yyyy-MM-dd")).ToArray()),
Series.From("Volume", linqSort.Select(r => r.Volume).ToArray()))| Symbol | Date | Volume |
|---|---|---|
| ISP.MI | 2023-08-08 | 376391539 |
| SAN.MC | 2021-10-20 | 367211467 |
| ISP.MI | 2023-05-31 | 317362978 |
| ISP.MI | 2023-03-13 | 311886033 |
| SAN.MC | 2021-11-03 | 306973344 |
Polars DataFrame — Sort
df.Sort("volume", descending: true).Head(5)| id | symbol | date | open | high | low | close | adj_close | volume | dividends | stock_splits | is_filled |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 31078 | ISP.MI | 2023-08-08 | 2.4 | 2.4165 | 2.3285 | 2.338 | 1.8961 | 376391539 | 0 | 0 | false |
| 10783 | SAN.MC | 2021-10-20 | 3.36 | 3.376 | 3.322 | 3.36 | 2.8379 | 367211467 | 0 | 0 | false |
| 31029 | ISP.MI | 2023-05-31 | 2.1925 | 2.2255 | 2.133 | 2.1555 | 1.7481 | 317362978 | 0 | 0 | false |
| 30975 | ISP.MI | 2023-03-13 | 2.4705 | 2.478 | 2.279 | 2.3305 | 1.8196 | 311886033 | 0 | 0 | false |
| 10793 | SAN.MC | 2021-11-03 | 3.275 | 3.31 | 3.236 | 3.31 | 2.8377 | 306973344 | 0 | 0 | false |
LINQ — Add computed column
var linqComputed = ohlcv.Take(5).Select(r => new { r.Symbol, r.Date, r.Close, Range = r.High - r.Low }).ToList();
new DataFrame(
Series.From("Symbol", linqComputed.Select(r => r.Symbol).ToArray()),
Series.From("Close", linqComputed.Select(r => r.Close).ToArray()),
Series.From("Range", linqComputed.Select(r => Math.Round(r.Range, 2)).ToArray()))| Symbol | Close | Range |
|---|---|---|
| ASML.AS | 406.25 | 8.75 |
| ASML.AS | 406.9 | 10.9 |
| ASML.AS | 402.85 | 8 |
| ASML.AS | 403.9 | 7.45 |
| ASML.AS | 416.05 | 5.7 |
Polars DataFrame — Add computed column
df.WithColumns((Col("high") - Col("low")).Alias("range")).Select("symbol", "close", "range").Head(5)| symbol | close | range |
|---|---|---|
| ABI.BR | 57.21 | 2.07 |
| ABI.BR | 57.18 | 1.23 |
| ABI.BR | 58.77 | 1.55 |
| ABI.BR | 58.4 | 0.98 |
| ABI.BR | 57.86 | 0.97 |
Aggregations
LINQ — GroupBy with aggregates
var linqAgg = ohlcv.GroupBy(r => r.Symbol)
.Select(g => new { Symbol = g.Key, AvgClose = Math.Round(g.Average(r => r.Close), 2), Count = g.Count() })
.OrderByDescending(s => s.AvgClose).Take(5).ToList();
new DataFrame(
Series.From("Symbol", linqAgg.Select(s => s.Symbol).ToArray()),
Series.From("AvgClose", linqAgg.Select(s => s.AvgClose).ToArray()),
Series.From("Count", linqAgg.Select(s => s.Count).ToArray()))| Symbol | AvgClose | Count |
|---|---|---|
| RMS.PA | 1761.56 | 1331 |
| ADYEN.AS | 1545.98 | 1331 |
| ASML.AS | 671.35 | 1331 |
| MC.PA | 662.4 | 1331 |
| RHM.DE | 544.66 | 1324 |
Polars DataFrame — GroupBy with aggregates
df.GroupBy("symbol").Agg(
Col("close").Mean().Alias("avg_close"),
Col("close").Count().Alias("count")
).Sort("avg_close", descending: true).Head(5)| symbol | avg_close | count |
|---|---|---|
| RMS.PA | 1761.555748 | 1331 |
| ADYEN.AS | 1545.976409 | 1331 |
| ASML.AS | 671.3489106 | 1331 |
| MC.PA | 662.4045079 | 1331 |
| RHM.DE | 544.6615332 | 1324 |
LINQ — HAVING
var linqHaving = ohlcv.GroupBy(r => r.Symbol)
.Select(g => new { Symbol = g.Key, AvgVol = g.Average(r => (double)r.Volume) })
.Where(s => s.AvgVol > 5_000_000)
.OrderByDescending(s => s.AvgVol).ToList();
new DataFrame(
Series.From("Symbol", linqHaving.Select(s => s.Symbol).ToArray()),
Series.From("AvgVol", linqHaving.Select(s => (long)s.AvgVol).ToArray()))| Symbol | AvgVol |
|---|---|
| ISP.MI | 87588601 |
| SAN.MC | 41770987 |
| ENEL.MI | 24678699 |
| BBVA.MC | 16654456 |
| UCG.MI | 13903710 |
| ENI.MI | 12976208 |
| INGA.AS | 12803589 |
| IBE.MC | 12034835 |
| DTE.DE | 7575084 |
| NDA-FI.HE | 5375454 |
| ... 1 more rows ... | |
Polars DataFrame — HAVING
df.GroupBy("symbol").Agg(
Col("volume").Mean().Alias("avg_vol")
).Filter(Col("avg_vol") > Lit(5_000_000.0)).Sort("avg_vol", descending: true)| symbol | avg_vol |
|---|---|
| ISP.MI | 87588601.04 |
| SAN.MC | 41770987.15 |
| ENEL.MI | 24678699.42 |
| BBVA.MC | 16654456.88 |
| UCG.MI | 13903710.14 |
| ENI.MI | 12976208.15 |
| INGA.AS | 12803589.45 |
| IBE.MC | 12034835.18 |
| DTE.DE | 7575084.131 |
| NDA-FI.HE | 5375454.051 |
| ... 1 more rows ... | |
Window Functions
LINQ — LAG
var asmlLinq = ohlcv.Where(r => r.Symbol == "ASML.AS").OrderBy(r => r.Date).ToList();
var linqLag = asmlLinq.Skip(1).Zip(asmlLinq, (curr, prev) => new
{ curr.Date, curr.Close, PrevClose = prev.Close,
Return = Math.Round((curr.Close - prev.Close) / prev.Close * 100, 2) })
.TakeLast(5).ToList();
new DataFrame(
Series.From("Date", linqLag.Select(r => r.Date.ToString("yyyy-MM-dd")).ToArray()),
Series.From("Close", linqLag.Select(r => r.Close).ToArray()),
Series.From("PrevClose", linqLag.Select(r => r.PrevClose).ToArray()),
Series.From("Return%", linqLag.Select(r => r.Return).ToArray()))| Date | Close | PrevClose | Return% |
|---|---|---|---|
| 2026-03-06 | 1147 | 1186 | -3.29 |
| 2026-03-09 | 1147.6 | 1147 | 0.05 |
| 2026-03-10 | 1200 | 1147.6 | 4.57 |
| 2026-03-11 | 1198.8 | 1200 | -0.1 |
| 2026-03-12 | 1190.8 | 1198.8 | -0.67 |
Polars DataFrame — LAG
df.Filter(Col("symbol") == Lit("ASML.AS"))
.Sort("date")
.WithColumns(Col("close").Shift(1).Over("symbol").Alias("prev_close"))
.WithColumns(((Col("close") - Col("prev_close")) / Col("prev_close") * Lit(100.0)).Alias("return_pct"))
.Select("date", "close", "prev_close", "return_pct")
.Tail(5)| date | close | prev_close | return_pct |
|---|---|---|---|
| 2026-03-06 | 1147 | 1186 | -3.28836425 |
| 2026-03-09 | 1147.6 | 1147 | 0.05231037489 |
| 2026-03-10 | 1200 | 1147.6 | 4.566050889 |
| 2026-03-11 | 1198.8 | 1200 | -0.1 |
| 2026-03-12 | 1190.8 | 1198.8 | -0.6673340007 |
LINQ — Cumulative SUM
var linqCum = ohlcv.Where(r => r.Symbol == "ASML.AS").OrderBy(r => r.Date)
.Aggregate(new List<(string D, long V, long C)>(),
(acc, r) => { acc.Add((r.Date.ToString("yyyy-MM-dd"), r.Volume,
(acc.Count > 0 ? acc[^1].C : 0) + r.Volume)); return acc; })
.TakeLast(5).ToList();
new DataFrame(
Series.From("Date", linqCum.Select(r => r.D).ToArray()),
Series.From("Volume", linqCum.Select(r => r.V).ToArray()),
Series.From("CumVol", linqCum.Select(r => r.C).ToArray()))| Date | Volume | CumVol |
|---|---|---|
| 2026-03-06 | 857271 | 942889692 |
| 2026-03-09 | 689086 | 943578778 |
| 2026-03-10 | 800815 | 944379593 |
| 2026-03-11 | 562904 | 944942497 |
| 2026-03-12 | 128223 | 945070720 |
Polars DataFrame — Cumulative SUM
df.Filter(Col("symbol") == Lit("ASML.AS"))
.Sort("date")
.WithColumns(Col("volume").CumSum().Over("symbol").Alias("cum_vol"))
.Select("date", "volume", "cum_vol")
.Tail(5)| date | volume | cum_vol |
|---|---|---|
| 2026-03-06 | 857271 | 942889692 |
| 2026-03-09 | 689086 | 943578778 |
| 2026-03-10 | 800815 | 944379593 |
| 2026-03-11 | 562904 | 944942497 |
| 2026-03-12 | 128223 | 945070720 |
LINQ — Rolling average
var W = 20;
var asmlSorted = ohlcv.Where(r => r.Symbol == "ASML.AS").OrderBy(r => r.Date).ToList();
var linqSma = Enumerable.Range(W - 1, asmlSorted.Count - W + 1)
.Select(i => new { asmlSorted[i].Date, asmlSorted[i].Close,
SMA = Math.Round(asmlSorted.Skip(i - W + 1).Take(W).Average(r => r.Close), 2) })
.TakeLast(5).ToList();
new DataFrame(
Series.From("Date", linqSma.Select(r => r.Date.ToString("yyyy-MM-dd")).ToArray()),
Series.From("Close", linqSma.Select(r => r.Close).ToArray()),
Series.From("SMA20", linqSma.Select(r => r.SMA).ToArray()))| Date | Close | SMA20 |
|---|---|---|
| 2026-03-06 | 1147 | 1214.02 |
| 2026-03-09 | 1147.6 | 1211.16 |
| 2026-03-10 | 1200 | 1211.51 |
| 2026-03-11 | 1198.8 | 1211.06 |
| 2026-03-12 | 1190.8 | 1211.61 |
Polars DataFrame — Rolling average
df.Filter(Col("symbol") == Lit("ASML.AS"))
.Sort("date")
.WithColumns(Col("close").RollingMean("20i").Over("symbol").Alias("sma_20"))
.Select("date", "close", "sma_20")
.Tail(5)| date | close | sma_20 |
|---|---|---|
| 2026-03-06 | 1147 | 1214.02 |
| 2026-03-09 | 1147.6 | 1211.16 |
| 2026-03-10 | 1200 | 1211.51 |
| 2026-03-11 | 1198.8 | 1211.06 |
| 2026-03-12 | 1190.8 | 1211.61 |
LINQ — ROW_NUMBER / Rank
var linqRank = ohlcv.GroupBy(r => r.Symbol)
.SelectMany(g => g.OrderByDescending(r => r.Volume)
.Select((r, i) => new { r.Symbol, r.Date, r.Volume, Rank = i + 1 })
.Where(r => r.Rank <= 1))
.OrderByDescending(r => r.Volume).Take(5).ToList();
new DataFrame(
Series.From("Symbol", linqRank.Select(r => r.Symbol).ToArray()),
Series.From("Date", linqRank.Select(r => r.Date.ToString("yyyy-MM-dd")).ToArray()),
Series.From("Volume", linqRank.Select(r => r.Volume).ToArray()))| Symbol | Date | Volume |
|---|---|---|
| ISP.MI | 2023-08-08 | 376391539 |
| SAN.MC | 2021-10-20 | 367211467 |
| BBVA.MC | 2021-09-17 | 228528294 |
| NDA-FI.HE | 2022-09-16 | 140675854 |
| PRX.AS | 2021-08-17 | 114772834 |
Polars DataFrame — ROW_NUMBER / Rank
df.WithColumns(Col("volume").Rank(descending: true).Over("symbol").Alias("vol_rank"))
.Filter(Col("vol_rank") == Lit(1))
.Sort("volume", descending: true)
.Select("symbol", "date", "volume")
.Head(5)| symbol | date | volume |
|---|---|---|
| ISP.MI | 2023-08-08 | 376391539 |
| SAN.MC | 2021-10-20 | 367211467 |
| BBVA.MC | 2021-09-17 | 228528294 |
| NDA-FI.HE | 2022-09-16 | 140675854 |
| PRX.AS | 2021-08-17 | 114772834 |
Joins
LINQ — Inner Join
var linqJoin = ohlcv.GroupBy(r => r.Symbol)
.Select(g => new { Symbol = g.Key, AvgClose = Math.Round(g.Average(r => r.Close), 2) })
.Join(scores, o => o.Symbol, s => s.Symbol,
(o, s) => new { o.Symbol, o.AvgClose, s.Sector, Rank = (int)s.CompositeRank })
.OrderBy(r => r.Rank).Take(5).ToList();
new DataFrame(
Series.From("Symbol", linqJoin.Select(r => r.Symbol).ToArray()),
Series.From("AvgClose", linqJoin.Select(r => r.AvgClose).ToArray()),
Series.From("Sector", linqJoin.Select(r => r.Sector).ToArray()),
Series.From("Rank", linqJoin.Select(r => r.Rank).ToArray()))| Symbol | AvgClose | Sector | Rank |
|---|---|---|---|
| BNP.PA | 60.94 | Financial Services | 1 |
| BNP.PA | 60.94 | Financial Services | 1 |
| BNP.PA | 60.94 | Financial Services | 1 |
| DTE.DE | 22.43 | Communication Services | 2 |
| DTE.DE | 22.43 | Communication Services | 2 |
Polars DataFrame — Inner Join
var dfAvg = df.GroupBy("symbol").Agg(Col("close").Mean().Alias("avg_close"));
var dfScores = new DataFrame(
Series.From("symbol", scores.Select(s => s.Symbol).ToArray()),
Series.From("sector", scores.Select(s => s.Sector).ToArray()),
Series.From("composite_rank", scores.Select(s => (int)s.CompositeRank).ToArray()));
dfAvg.Join(dfScores, new[] { Col("symbol") }, new[] { Col("symbol") })
.Select("symbol", "avg_close", "sector", "composite_rank")
.Sort("composite_rank")
.Head(5)| symbol | avg_close | sector | composite_rank |
|---|---|---|---|
| BNP.PA | 60.93771225 | Financial Services | 1 |
| BNP.PA | 60.93771225 | Financial Services | 1 |
| BNP.PA | 60.93771225 | Financial Services | 1 |
| DTE.DE | 22.43009743 | Communication Services | 2 |
| DTE.DE | 22.43009743 | Communication Services | 2 |
CRUD-like Operations
LINQ — Add rows with Concat()
Concat appends one IEnumerable to another lazily — no copy, no allocation. It returns a new sequence that yields elements from both, equivalent to SQL UNION ALL.
var newRows = new[] { new Ohlcv("TEST.XX", DateTime.Today, 100, 105, 95, 102, 102, 50000) };
var linqInsert = ohlcv.Concat(newRows).TakeLast(3).ToList();
Console.WriteLine($" LINQ: {ohlcv.Count} + {newRows.Length} = {ohlcv.Count + newRows.Length} rows (Concat)");
new DataFrame(
Series.From("Symbol", linqInsert.Select(r => r.Symbol).ToArray()),
Series.From("Date", linqInsert.Select(r => r.Date.ToString("yyyy-MM-dd")).ToArray()),
Series.From("Close", linqInsert.Select(r => r.Close).ToArray()),
Series.From("Volume", linqInsert.Select(r => r.Volume).ToArray()))66355 + 1 = 66356 rows (Concat)| Symbol | Date | Close | Volume |
|---|---|---|---|
| WKL.AS | 2026-03-12 | 67.32 | 210379 |
| DSY.PA | 2026-03-12 | 18.37 | 434417 |
| TEST.XX | 2026-03-28 | 102 | 50000 |
Polars DataFrame — Add rows with VStack()
VStack vertically stacks two DataFrames (appends rows). Both must have identical column names and types. Equivalent to SQL UNION ALL.
var newDf = new DataFrame(
Series.From("id", new[] { 0L }),
Series.From("symbol", new[] { "TEST.XX" }),
Series.From("date", new[] { DateOnly.FromDateTime(DateTime.Today) }),
Series.From("open", new[] { 100.0 }),
Series.From("high", new[] { 105.0 }),
Series.From("low", new[] { 95.0 }),
Series.From("close", new[] { 102.0 }),
Series.From("adj_close", new[] { 102.0 }),
Series.From("volume", new[] { 50000L }),
Series.From("dividends", new[] { 0.0 }),
Series.From("stock_splits", new[] { 0.0 }),
Series.From("is_filled", new[] { false }));
var dfInserted = df.VStack(newDf);
Console.WriteLine($" Polars: {df.Height} + {newDf.Height} = {dfInserted.Height} rows (VStack)");
dfInserted.Tail(3)66355 + 1 = 66356 rows (VStack)| id | symbol | date | open | high | low | close | adj_close | volume | dividends | stock_splits | is_filled |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 66877 | WKL.AS | 2026-03-11 | 67.5 | 69.6 | 67.02 | 67.22 | 67.22 | 1142531 | 0 | 0 | false |
| 66929 | WKL.AS | 2026-03-12 | 67 | 67.54 | 66.28 | 67.32 | 67.32 | 210379 | 0 | 0 | false |
| 0 | TEST.XX | 2026-03-28 | 100 | 105 | 95 | 102 | 102 | 50000 | 0 | 0 | false |
LINQ — Update column
LINQ doesn’t mutate in place — Select projects each element into a new anonymous type with the modified property, leaving the original collection untouched.
var linqUpdate = ohlcv.Where(r => r.Symbol == "ASML.AS").Take(5)
.Select(r => new { r.Symbol, r.Date, AdjClose = r.Close * 1.05 }).ToList();
new DataFrame(
Series.From("Symbol", linqUpdate.Select(r => r.Symbol).ToArray()),
Series.From("Date", linqUpdate.Select(r => r.Date.ToString("yyyy-MM-dd")).ToArray()),
Series.From("AdjClose", linqUpdate.Select(r => Math.Round(r.AdjClose, 2)).ToArray()))| Symbol | Date | AdjClose |
|---|---|---|
| ASML.AS | 2021-01-04 | 426.56 |
| ASML.AS | 2021-01-05 | 427.24 |
| ASML.AS | 2021-01-06 | 422.99 |
| ASML.AS | 2021-01-07 | 424.1 |
| ASML.AS | 2021-01-08 | 436.85 |
Polars DataFrame — Update column
WithColumns replaces or creates a column by expression — Polars is also immutable, returning a new DataFrame rather than modifying the original.
df.Filter(Col("symbol") == Lit("ASML.AS"))
.WithColumns((Col("close") * Lit(1.05)).Alias("adj_close"))
.Select("symbol", "date", "adj_close")
.Head(5)| symbol | date | adj_close |
|---|---|---|
| ASML.AS | 2021-01-04 | 426.5625 |
| ASML.AS | 2021-01-05 | 427.245 |
| ASML.AS | 2021-01-06 | 422.9925 |
| ASML.AS | 2021-01-07 | 424.095 |
| ASML.AS | 2021-01-08 | 436.8525 |
LINQ — Delete rows
Deleting is the inverse of filtering — Where keeps non-matching rows, effectively excluding the “deleted” ones from the resulting sequence.
var linqDelete = ohlcv.Where(r => r.Symbol != "ASML.AS");
Console.WriteLine($" LINQ: {ohlcv.Count} - ASML rows = {linqDelete.Count()} remaining");66355 - ASML rows = 65024 remainingPolars DataFrame — Delete rows
Same pattern — Filter with a negated condition returns a new DataFrame without the excluded rows.
var dfFiltered = df.Filter(Col("symbol") != Lit("ASML.AS"));
Console.WriteLine($" Polars: {df.Height} - ASML rows = {dfFiltered.Height} remaining");66355 - ASML rows = 65024 remainingLINQ — Drop column
LINQ has no native Drop — project only the columns you want via Select, omitting the unwanted properties.
var linqDrop = ohlcv.Take(3).Select(r => new
{ r.Symbol, r.Date, r.Open, r.High, r.Low, r.Close, r.AdjClose, r.Volume }).ToList();
new DataFrame(
Series.From("Symbol", linqDrop.Select(r => r.Symbol).ToArray()),
Series.From("Date", linqDrop.Select(r => r.Date.ToString("yyyy-MM-dd")).ToArray()),
Series.From("Open", linqDrop.Select(r => r.Open).ToArray()),
Series.From("High", linqDrop.Select(r => r.High).ToArray()),
Series.From("Low", linqDrop.Select(r => r.Low).ToArray()),
Series.From("Close", linqDrop.Select(r => r.Close).ToArray()),
Series.From("AdjClose", linqDrop.Select(r => r.AdjClose).ToArray()),
Series.From("Volume", linqDrop.Select(r => r.Volume).ToArray()))| Symbol | Date | Open | High | Low | Close | AdjClose | Volume |
|---|---|---|---|---|---|---|---|
| ASML.AS | 2021-01-04 | 404 | 411 | 402.25 | 406.25 | 387.709 | 789502 |
| ASML.AS | 2021-01-05 | 406.55 | 412.05 | 401.15 | 406.9 | 388.3294 | 798787 |
| ASML.AS | 2021-01-06 | 406.8 | 407.2 | 399.2 | 402.85 | 384.4644 | 875711 |
Polars DataFrame — Drop column
Select all columns except the ones to remove — Polars doesn’t have a .Drop() method, so you filter the column name list and pass it to Select.
var dropCols = new HashSet<string> { "dividends", "stock_splits", "is_filled" };
var keepCols = df.Columns.Where(n => !dropCols.Contains(n)).ToArray();
df.Select(keepCols).Head(3)| id | symbol | date | open | high | low | close | adj_close | volume |
|---|---|---|---|---|---|---|---|---|
| 21160 | ABI.BR | 2021-01-04 | 58.15 | 58.85 | 56.78 | 57.21 | 53.5761 | 1513937 |
| 21161 | ABI.BR | 2021-01-05 | 56.9 | 57.98 | 56.75 | 57.18 | 53.548 | 1382722 |
| 21162 | ABI.BR | 2021-01-06 | 57.96 | 58.94 | 57.39 | 58.77 | 55.037 | 1370204 |
C# Generics and LINQ Warnings
LINQ deferred execution — query re-executes on each enumeration
A LINQ query like
var q = data.Where(x => x > 5)is a description, not a result. Everyforeach,.Count(), or.ToList()re-executes the full pipeline from scratch — including any side effects or database calls.
Correct pattern
Materialize early with
.ToList()or.ToArray()when you need the results more than once:var results = data.Where(x => x > 5).ToList();.
Forgetting generic constraints causes compile errors
Calling
item.CompareTo(other)on an unconstrainedTdoesn’t compile — the compiler doesn’t knowThas that method.
Correct pattern
Add the constraint:
where T : IComparable<T>. Only add constraints you actually use — over-constraining reduces reusability.
GroupByreturnsIGrouping, not a dictionaryThe result of
GroupByis a lazy sequence ofIGrouping<TKey, TElement>. Accessing.Keygives the group key; enumerating gives the group’s elements. It’s not aDictionary.
Correct pattern
To get a dictionary, chain
.ToDictionary(g => g.Key, g => g.ToList()). To process groups lazily, iterate theIGroupingdirectly.
Aggregatethrows on empty sequencesThe seedless overload
data.Aggregate((a, b) => a + b)throwsInvalidOperationExceptionif the source is empty.
Correct pattern
Use the seeded overload:
data.Aggregate(0, (acc, x) => acc + x). The seed serves as both the initial value and the return value for empty sequences.
Variance only applies to interfaces and delegates
class MyList<out T>doesn’t compile — covariance (out) and contravariance (in) only work on interface and delegate type parameters.
Correct pattern
Define variance on the interface:
interface IReadable<out T>. The implementing class uses invariantT:class Readable<T> : IReadable<T>.
C# Generics and LINQ Recommendations
- Prefer method syntax for most LINQ — it’s more composable and the dominant style in production C#. Use query syntax for complex joins and
letbindings. - Materialize LINQ results with
.ToList()when you need stable, reusable data — deferred execution re-evaluates on each enumeration. - Use generic constraints sparingly — only add constraints you actually need.
where T : classorwhere T : IComparable<T>should serve a purpose in the method body. - Use
IEnumerable<T>as parameter types — accept the broadest interface so callers can pass arrays, lists, LINQ queries, or any collection. - Use Dapper for SQL → LINQ pipelines — Dapper maps SQL results directly to strongly typed records, then LINQ operates on the typed collection in memory.
- Use Polars.NET for large analytical workloads — when LINQ on in-memory collections isn’t fast enough, Polars.NET brings columnar Rust-backed processing to C#.
- Use
recordtypes for LINQ projections —record PriceRow(string Symbol, DateTime Date, double Close)gives free equality,ToString, and deconstruction. - Use covariance (
out T) on read-only interfaces — enablesIEnumerable<Dog>whereIEnumerable<Animal>is expected, which is safe because you’re only reading.
C# Generics and LINQ Troubleshooting
| Problem | Cause | Fix |
|---|---|---|
CS0311: type 'X' cannot be used as type parameter 'T' | X doesn’t satisfy the generic constraint | Add the required interface to X, or relax the constraint |
| LINQ query returns different results on second enumeration | Deferred execution re-evaluates — source data changed between enumerations | Materialize with .ToList() after the first evaluation |
InvalidOperationException: Sequence contains no elements | Called First(), Single(), or seedless Aggregate() on an empty sequence | Use FirstOrDefault(), SingleOrDefault(), or seeded Aggregate(seed, func) |
GroupBy result is hard to work with | IGrouping<K,V> isn’t a dictionary | Chain .ToDictionary(g => g.Key, g => g.ToList()) to convert |
CS1061: 'T' does not contain a definition for 'X' | Missing constraint — compiler doesn’t know T has method X | Add where T : IInterface with the required method |
| Covariance/contravariance won’t compile on class | Variance only works on interfaces and delegates | Move out/in to an interface definition |
| LINQ query is slow on large data | All data loaded in memory, no optimization | Consider Polars.NET for columnar processing, or push filtering into SQL |
Polars.NET DataFrame column access throws | Column name mismatch or wrong type | Check column names with df.Columns and types with df.Schema |
Aggregate produces wrong result | Seed value is wrong, or accumulator function has a bug | Verify seed and step through the accumulator logic manually |
Query syntax let not available in method syntax | let is a query-syntax-only keyword | Use a .Select() to create an intermediate anonymous type |