LINQ and Query Expressions

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Visual Basic 17.13 on .NET 10 (SDK 10.0.402) and was checked on 2026-10-19. Every example is a complete console program that starts with Option Strict On, was built with warnings treated as errors (except where the text says a warning is the subject) and was run in the .NET 10 SDK container (Linux); the output shown is real output. The examples that are meant to fail say so and show the compiler messages that the SDK printed, with the file path removed. The project-level imports that the SDK adds (System, System.Collections.Generic, System.Linq and others) are why List(Of T) and the query operators need no Imports line; System.Data and System.Linq.Expressions are imported where they are used. To keep every program self-contained, the examples declare small Book, Author and Loan classes with the members they need, instead of referencing the full shelf domain model; the names and the data follow the shelf library catalogue.

Language Integrated Query (LINQ) puts a declarative query language into the language itself: the same From ... Where ... Select text works over arrays, lists, XML trees, DataTable rows and, through other providers, databases. A query expression is not a separate engine. The compiler translates every clause into a call of a query operator method (Where, Select, OrderBy and so on) that is looked up by name on the source, and the arguments are lambda expressions built from the clause text. What that call returns, and when the work happens, is decided by the method that is found, which is the reason the same syntax can be a lazy in-memory pipeline over IEnumerable(Of T) or a SQL statement over IQueryable(Of T).

Visual Basic had LINQ from the start. It arrived with Visual Basic 9 and Visual Studio 2008, together with local type inference, anonymous types, extension methods, lambda expressions and XML literals (see XML Literals and Axis Properties). Its query syntax is richer than C#'s: Aggregate, Distinct, Skip, Take, Skip While, Take While and Group By ... Into are query clauses in Visual Basic, and they have no query-syntax counterpart in C#, where they exist only as method calls. The method-based operators of System.Linq are shared and are the same in both languages; the C# reference covers them in LINQ in C#.

The query clauses at a glance

Clause What it does Translated into

From x In source

starts a query and declares the range variable x; more From clauses nest

the source itself, or SelectMany

Where condition

keeps the elements for which the Boolean condition is true

Where

Select projection

chooses what the query returns; may name several members, which produces an anonymous type

Select

Order By k1 Ascending, k2 Descending ...

sorts; each key takes Ascending (the default) or Descending, and later keys break ties

OrderBy, OrderByDescending, ThenBy, ThenByDescending

Join y In other On a Equals b [And c Equals d]

inner join on equal keys

Join

Group Join y In other On a Equals b Into g = Group

joins and keeps every outer element with its (possibly empty) group of matches

GroupJoin

Group [element] By key Into ...

groups by key and computes aggregates per group

GroupBy

Let name = expression

introduces a new range variable computed once per element

Select (carrying the old variables along)

Distinct

removes duplicate results

Distinct

Skip n, Take n

drops or keeps a fixed number of elements

Skip, Take

Skip While condition, Take While condition

drops or keeps elements while the condition holds

SkipWhile, TakeWhile

Aggregate x In source ... Into f()

computes one or more aggregates and returns the value (or a record of values) immediately

Count, Sum, Min, Max, Average, Any, All and others

Rules shared by all clauses:

  • A query has exactly one first clause, which is From or Aggregate. Every later clause works on the range variables that are still in scope.

  • Clauses may be repeated and, unlike in C#, mostly reordered: Select can come before Where, and a query can contain several Select, Where and Order By clauses. A Select is optional; without one the query returns the range variable itself.

  • The query is one logical line. Line breaks are allowed before each clause keyword, and the example listings use that to put one clause per line (see Program Structure and Code Conventions).

  • A query cannot contain statements, only expressions. Any ByRef parameter used in a query is rejected (BC36533, shown below).

The clause pipeline

Reading a query top to bottom shows how data flows: each clause receives the sequence that the previous one produced. Group By ... Into is the one clause that changes what the range variables are, so the diagram splits there.

flowchart TD A(["Source: an array, a List, an XML axis, a DataTable view, an IQueryable"]) --> B["From x In source : declares the range variable x"] B --> C["Join, Group Join, Let, further From : widen or enrich the rows"] C --> D["Where : keep the rows that pass"] D --> E{"Group By ... Into ?"} E -->|"yes"| F["Group By ... Into : the range variables are replaced by the key and the aggregates"] E -->|"no"| G["Order By : sort, ties broken by later keys"] F --> G G --> H["Select : choose what each row becomes"] H --> I["Distinct, Skip, Take, Skip While, Take While : trim the result"] I --> J(["Result: a lazy IEnumerable or IQueryable, run when it is enumerated"]) B -.-> K["Aggregate ... Into : an alternative first clause that runs now and returns a value"]

The sections below follow the pipeline. The first example is a complete query next to the same query in method syntax; the two are the same program for the compiler, which SequenceEqual confirms (the Key keywords the method form needs are explained in the section on anonymous types).

Option Strict On

Public Class Book
    Public Property Title As String
    Public Property Author As String
    Public Property Year As Integer
    Public Property Pages As Integer
    Public Property Genre As String

    Public Sub New(title As String, author As String, year As Integer, pages As Integer, genre As String)
        Me.Title = title
        Me.Author = author
        Me.Year = year
        Me.Pages = pages
        Me.Genre = genre
    End Sub
End Class

Module Program
    Function Library() As List(Of Book)
        Return New List(Of Book) From {
            New Book("Dune", "Frank Herbert", 1965, 412, "Science fiction"),
            New Book("Emma", "Jane Austen", 1815, 474, "Novel"),
            New Book("Neuromancer", "William Gibson", 1984, 271, "Science fiction"),
            New Book("Persuasion", "Jane Austen", 1817, 249, "Novel"),
            New Book("Hyperion", "Dan Simmons", 1989, 482, "Science fiction"),
            New Book("Ulysses", "James Joyce", 1922, 730, "Novel")
        }
    End Function

    Sub Main()
        Dim books = Library()

        ' Query syntax: From, Where, Order By, Select
        Dim recent = From b In books
                     Where b.Year > 1900 AndAlso b.Genre = "Science fiction"
                     Order By b.Year Descending
                     Select b.Title, b.Year

        For Each r In recent
            Console.WriteLine(r.Title & " (" & r.Year & ")")
        Next

        ' The same query in method syntax
        Dim sameQuery = books.
            Where(Function(b) b.Year > 1900 AndAlso b.Genre = "Science fiction").
            OrderByDescending(Function(b) b.Year).
            Select(Function(b) New With {Key b.Title, Key b.Year})

        Console.WriteLine(recent.SequenceEqual(sameQuery))
    End Sub
End Module
Hyperion (1989)
Neuromancer (1984)
Dune (1965)
True

Range variables, type inference and the type of a query

From b In books is neither an assignment nor a loop. It declares b as the range variable of the query: it stands for one element at a time and is typed from the element type of books (here Book). The source can be anything that has the query operator methods in scope, which in practice means IEnumerable(Of T) (through the extension methods of System.Linq.Enumerable), IQueryable(Of T) (through System.Linq.Queryable) and any type that supplies its own methods. A type that is not queryable produces BC36593, as shown below.

The query result is an unexecuted query object. Its static type is rarely worth writing out: with Select b.Title, b.Year it is an IEnumerable of a compiler-generated anonymous type that has no name, so the declaration must rely on local type inference. That is the reason Option Infer On (the SDK default; see Option Statements) is practically a requirement for query code. With Option Infer Off and Option Strict On the same declaration is rejected:

Option Strict On
Option Infer Off

Module Program
    Sub Main()
        Dim numbers As New List(Of Integer)
        Dim q = From n In numbers Select n
    End Sub
End Module
Program.vb(7,13): error BC30209: Option Strict On requires all variable declarations to have an 'As' clause.

A query whose Select returns an existing type has a nameable type, IEnumerable(Of T), and may be assigned to a variable declared with it; with a Select of several members it cannot be.

Filtering, ordering, projecting and trimming

Where takes any Boolean expression, and AndAlso and OrElse short-circuit inside it as everywhere else. Order By takes a comma-separated list of keys, each optionally followed by Ascending (the default) or Descending; the sort is stable, so equal keys keep their source order. Select can be followed by the trimming clauses Distinct, Skip, Take, Skip While and Take While. The next program shows each clause on one small data set; every For Each or String.Join runs the query at that moment.

Option Strict On

Public Class Book
    Public Property Title As String
    Public Property Author As String
    Public Property Year As Integer
    Public Sub New(title As String, author As String, year As Integer)
        Me.Title = title
        Me.Author = author
        Me.Year = year
    End Sub
End Class

Module Program
    Sub Main()
        Dim books As New List(Of Book) From {
            New Book("Emma", "Jane Austen", 1815),
            New Book("Persuasion", "Jane Austen", 1817),
            New Book("Dune", "Frank Herbert", 1965),
            New Book("Ulysses", "James Joyce", 1922),
            New Book("Dubliners", "James Joyce", 1914),
            New Book("Neuromancer", "William Gibson", 1984)
        }

        Console.WriteLine("-- Order By author, then year descending")
        For Each b In From x In books Order By x.Author, x.Year Descending Select x
            Console.WriteLine(b.Author & " / " & b.Year & " / " & b.Title)
        Next

        Console.WriteLine("-- Distinct authors")
        Console.WriteLine(String.Join("; ", From b In books Select b.Author Distinct))

        Console.WriteLine("-- Skip 1, Take 3 over Order By Year")
        Console.WriteLine(String.Join("; ", From b In books Order By b.Year Select b.Title Skip 1 Take 3))

        Console.WriteLine("-- Skip While year < 1960 (in source order)")
        Console.WriteLine(String.Join("; ", From b In books Skip While b.Year < 1960 Select b.Title))

        Console.WriteLine("-- Take While year < 1960 (stops at the first failure)")
        Console.WriteLine(String.Join("; ", From b In books Take While b.Year < 1960 Select b.Title))

        Console.WriteLine("-- Where after Select, with the projected name")
        Console.WriteLine(String.Join("; ", From b In books Select b.Title, b.Year Where Year > 1950 Select Title))
    End Sub
End Module
-- Order By author, then year descending
Frank Herbert / 1965 / Dune
James Joyce / 1922 / Ulysses
James Joyce / 1914 / Dubliners
Jane Austen / 1817 / Persuasion
Jane Austen / 1815 / Emma
William Gibson / 1984 / Neuromancer
-- Distinct authors
Jane Austen; Frank Herbert; James Joyce; William Gibson
-- Skip 1, Take 3 over Order By Year
Persuasion; Dubliners; Ulysses
-- Skip While year < 1960 (in source order)
Dune; Ulysses; Dubliners; Neuromancer
-- Take While year < 1960 (stops at the first failure)
Emma; Persuasion
-- Where after Select, with the projected name
Dune; Neuromancer

Points worth remembering from that program:

  • Skip While and Take While test the elements in source order and stop testing at the first element that decides the matter. With Skip While b.Year < 1960 the prefix Emma and Persuasion is dropped, and everything after Dune is kept, even Ulysses (1922) and Dubliners (1914), which are older than 1960. Take While b.Year < 1960 is the mirror image: it ends the result at Dune and never examines the older books after it. Neither is a filter; use Where for that.

  • Skip and Take are clauses, not methods, and apply to the query as written so far. Written after Order By, they page the sorted sequence; written before it, they would cut the unsorted sequence.

  • Distinct compares the projected elements. With a single member it compares values; with several members it compares the anonymous-type instances, whose equality depends on the Key properties (explained below).

  • A Where may follow a Select. The projected names (Title, Year) are then the range variables for the later clauses and the original b is no longer visible.

Deferred and immediate execution

Defining a query does not run it. The query object holds the source reference and the lambdas; the work happens when something asks for the elements: a For Each, String.Join, ToList(), ToArray(), Count(), First(), Aggregate and every other operator that returns a value rather than a sequence. Two consequences follow, and the program shows both. First, a query that is enumerated twice does its work twice. Second, a query sees the source and the captured variables as they are when it runs, not as they were when it was written.

Option Strict On

Module Program
    Sub Main()
        Dim numbers As New List(Of Integer) From {1, 2, 3}

        ' Deferred: nothing runs here
        Dim evens = From n In numbers
                    Where n Mod 2 = 0
                    Select n

        ' Immediate: ToList, Count(), Sum() and friends run the query now
        Dim snapshot = evens.ToList()
        Dim total = (From n In numbers Select n).Sum()

        numbers.Add(4)
        numbers.Add(6)

        Console.WriteLine("snapshot: " & String.Join(",", snapshot))
        Console.WriteLine("evens   : " & String.Join(",", evens))
        Console.WriteLine("total   : " & total)

        ' A query sees the variables it captures when it runs, not when it is written
        Dim limit As Integer = 3
        Dim small = From n In numbers Where n <= limit Select n
        limit = 4
        Console.WriteLine("limit 4 : " & String.Join(",", small))

        ' Tracing the pipeline: each element flows through all stages before the next one starts
        Dim traced = From n In numbers.Where(Function(x)
                                                 Console.WriteLine("  where  " & x)
                                                 Return x > 2
                                             End Function)
                     Select Doubled = Doubler(n)

        Console.WriteLine("query defined, nothing traced yet")
        For Each d In traced
            Console.WriteLine("got " & d)
        Next
    End Sub

    Function Doubler(n As Integer) As Integer
        Console.WriteLine("  select " & n)
        Return n * 2
    End Function
End Module
snapshot: 2
evens   : 2,4,6
total   : 6
limit 4 : 1,2,3,4
query defined, nothing traced yet
  where  1
  where  2
  where  3
  select 3
got 6
  where  4
  select 4
got 8
  where  6
  select 6
got 12

The trace at the end of the output also shows how the pipeline works: the operators are stacked iterators, so each element travels through Where and Select before the next element is touched. Nothing is buffered between them, except by the operators that must see everything before they can emit anything: Order By, Group By, the inner side of Join and Group Join, and Reverse. That is why a pipeline of Where and Select can start returning results immediately, while the first result of an Order By is available only after the whole source has been read.

Execution style Operators and keywords

Deferred, streaming

Where, Select, Skip, Take, Skip While, Take While, Distinct, Let, the second From of a flattening query (SelectMany), Concat, Zip, Union

Deferred, buffering

Order By, Group By, Join and Group Join (their inner source), Except and Intersect (their second source), Reverse

Immediate (returns a value)

Aggregate ... Into, Count(), Sum(), Min(), Max(), Average(), Any(), All(), First(), Single(), Last(), ElementAt(), Contains()

Immediate (materialises a collection)

ToList(), ToArray(), ToDictionary(), ToHashSet(), ToLookup()

The classic pitfalls of deferral are the next program: running a query several times, modifying the source while it is being enumerated, and calling an operator that needs an element on an empty sequence. Sum of nothing is 0, but Max, Min and Average of a sequence of non-nullable values have no answer and throw InvalidOperationException; use FirstOrDefault, DefaultIfEmpty or a nullable projection when emptiness is possible.

Option Strict On

Module Program
    Sub Main()
        Dim names As New List(Of String) From {"emma", "dune"}

        ' 1. Multiple enumeration runs the query again
        Dim runs As Integer = 0
        Dim q = From n In names
                Let counted = Tick(runs)
                Select n.ToUpper()
        Console.WriteLine(q.Count() & " " & q.Count() & " runs=" & runs)

        ' 2. Changing the source while a query is being enumerated
        Try
            For Each n In From x In names Select x
                names.Add(n & "2")
            Next
        Catch ex As InvalidOperationException
            Console.WriteLine("modify while iterating: " & ex.Message)
        End Try

        ' 3. First/Single/Max on an empty sequence
        Dim empty = From n In New List(Of Integer) Select n
        Try
            Console.WriteLine(empty.First())
        Catch ex As InvalidOperationException
            Console.WriteLine("First : " & ex.Message)
        End Try
        Try
            Console.WriteLine(Aggregate n In empty Into Max())
        Catch ex As InvalidOperationException
            Console.WriteLine("Max   : " & ex.Message)
        End Try
        Console.WriteLine("Sum of nothing: " & Aggregate n In empty Into Sum())
        Try
            Console.WriteLine(Aggregate n In empty Into Average())
        Catch ex As InvalidOperationException
            Console.WriteLine("Average: " & ex.Message)
        End Try
    End Sub

    Function Tick(ByRef runs As Integer) As Integer
        runs += 1
        Return runs
    End Function
End Module
2 2 runs=4
modify while iterating: Collection was modified; enumeration operation may not execute.
First : Sequence contains no elements
Max   : Sequence contains no elements
Sum of nothing: 0
Average: Sequence contains no elements

Capturing the loop variable

A query captures variables, not values. A For loop control variable is a single variable that changes during the loop, so a query created in the loop and enumerated after it sees the final value. The compiler warns about it (BC42327); under TreatWarningsAsErrors the program does not build, so the next listing is built with that one setting turned off in the project to show the warning and the effect. Copy the loop variable to a local declared inside the loop (the second list) to get one variable per iteration.

Option Strict On

Module Program
    Sub Main()
        ' Lambdas created in a loop capture the variable, not its value at that moment
        Dim queries As New List(Of IEnumerable(Of Integer))
        Dim numbers = {1, 2, 3, 4, 5, 6}

        For divisor As Integer = 2 To 3
            queries.Add(From n In numbers Where n Mod divisor = 0 Select n)
        Next
        ' The loop variable of For ... Next is shared by every iteration (BC42327 is a warning)

        For Each q In queries
            Console.WriteLine(String.Join(",", q))
        Next

        Dim fixedQueries As New List(Of IEnumerable(Of Integer))
        For d As Integer = 2 To 3
            Dim divisor As Integer = d
            fixedQueries.Add(From n In numbers Where n Mod divisor = 0 Select n)
        Next
        For Each q In fixedQueries
            Console.WriteLine(String.Join(",", q))
        Next
    End Sub
End Module
Program.vb(10,55): warning BC42327: Using the iteration variable in a query expression may have unexpected results.  Instead, create a local variable within the loop and assign it the value of the iteration variable.
4
4
2,4,6
3,6

Joins

Join is an inner join. Both sides of the Equals keyword must each mention range variables from only one side of the join (the left key uses the variables defined before the Join, the right key uses only the new one), and the keyword is Equals, not =. Several key pairs are joined with And. A Group Join keeps every outer element together with its group of matches, which may be empty, and is also how a left outer join is written: flatten the group with DefaultIfEmpty() in a second From.

Option Strict On

Public Class Author
    Public Property Id As Integer
    Public Property Name As String
    Public Sub New(id As Integer, name As String)
        Me.Id = id
        Me.Name = name
    End Sub
End Class

Public Class Book
    Public Property Title As String
    Public Property AuthorId As Integer
    Public Property Year As Integer
    Public Property Pages As Integer
    Public Sub New(title As String, authorId As Integer, year As Integer, pages As Integer)
        Me.Title = title
        Me.AuthorId = authorId
        Me.Year = year
        Me.Pages = pages
    End Sub
End Class

Public Class Review
    Public Property Title As String
    Public Property Year As Integer
    Public Property Stars As Integer
    Public Sub New(title As String, year As Integer, stars As Integer)
        Me.Title = title
        Me.Year = year
        Me.Stars = stars
    End Sub
End Class

Module Program
    Sub Main()
        Dim authors As New List(Of Author) From {
            New Author(1, "Jane Austen"), New Author(2, "Frank Herbert"), New Author(3, "Anonymous")
        }
        Dim books As New List(Of Book) From {
            New Book("Emma", 1, 1815, 474), New Book("Persuasion", 1, 1817, 249),
            New Book("Dune", 2, 1965, 412), New Book("Orphan", 99, 2001, 100)
        }

        Console.WriteLine("-- Inner join: only matching pairs")
        Dim pairs = From b In books
                    Join a In authors On b.AuthorId Equals a.Id
                    Select b.Title, AuthorName = a.Name
        For Each p In pairs
            Console.WriteLine(p.Title & " <- " & p.AuthorName)
        Next

        Console.WriteLine("-- Group Join: every author, with a possibly empty group of books")
        Dim shelves = From a In authors
                      Group Join b In books On a.Id Equals b.AuthorId Into Titles = Group
                      Select a.Name, Count = Titles.Count(), Titles
        For Each s In shelves
            Console.WriteLine(s.Name & ": " & s.Count & " [" & String.Join(", ", From t In s.Titles Select t.Title) & "]")
        Next

        Console.WriteLine("-- Left outer join = Group Join + Select Many with DefaultIfEmpty")
        Dim leftJoin = From a In authors
                       Group Join b In books On a.Id Equals b.AuthorId Into g = Group
                       From x In g.DefaultIfEmpty()
                       Select a.Name, Title = If(x Is Nothing, "(none)", x.Title)
        For Each l In leftJoin
            Console.WriteLine(l.Name & " / " & l.Title)
        Next

        Console.WriteLine("-- Composite key: the pairs on each side of And are compared with Equals")
        Dim reviews As New List(Of Review) From {
            New Review("Emma", 1815, 5), New Review("Emma", 1816, 1), New Review("Dune", 1965, 4)
        }
        Dim matched = From b In books
                      Join r In reviews On b.Title Equals r.Title And b.Year Equals r.Year
                      Select b.Title, r.Stars
        For Each m In matched
            Console.WriteLine(m.Title & " " & m.Stars)
        Next

        Console.WriteLine("-- Join with a third source (chained Join)")
        Dim chain = From b In books
                    Join a In authors On b.AuthorId Equals a.Id
                    Join a2 In authors On a.Id Equals a2.Id
                    Select b.Title, a2.Name
        Console.WriteLine(chain.Count())
    End Sub
End Module
-- Inner join: only matching pairs
Emma <- Jane Austen
Persuasion <- Jane Austen
Dune <- Frank Herbert
-- Group Join: every author, with a possibly empty group of books
Jane Austen: 2 [Emma, Persuasion]
Frank Herbert: 1 [Dune]
Anonymous: 0 []
-- Left outer join = Group Join + Select Many with DefaultIfEmpty
Jane Austen / Emma
Jane Austen / Persuasion
Frank Herbert / Dune
Anonymous / (none)
-- Composite key: the pairs on each side of And are compared with Equals
Emma 5
Dune 4
-- Join with a third source (chained Join)
3

Two behaviours matter in practice. The join keys are compared with the default equality of the key type: strings are compared case-sensitively whatever Option Compare says (see the section on comparison below). And a Join builds a lookup of the inner source before it starts, so the inner source is read completely, while the outer source streams. Put the smaller sequence on the inner side.

NET 10 added Enumerable.LeftJoin and Enumerable.RightJoin, so the Group Join plus DefaultIfEmpty() idiom is no longer the

only way to write an outer join, but they exist only as method calls; there is no Left Join clause. The call below runs on the .NET 10 SDK. The elements are anonymous types declared in array initializers.

Option Strict On

Module Program
    Sub Main()
        ' Enumerable.LeftJoin and RightJoin were added in .NET 10
        Dim authors = {New With {Key .Id = 1, Key .Name = "Austen"}, New With {Key .Id = 2, Key .Name = "Anonymous"}}
        Dim titles = {New With {Key .AuthorId = 1, Key .Title = "Emma"}}

        Dim result = authors.LeftJoin(titles,
                                      Function(a) a.Id,
                                      Function(t) t.AuthorId,
                                      Function(a, t) a.Name & ": " & If(t Is Nothing, "(none)", t.Title))
        For Each r In result
            Console.WriteLine(r)
        Next
    End Sub
End Module
Austen: Emma
Anonymous: (none)

Grouping and aggregating

Group By partitions the sequence by a key. The Into list that follows is required: it names what each group contributes to the result. It may contain the aggregate functions (Count(), Sum(expr), Min, Max, Average, Any, All, and any extension method that takes the group) and the group itself under any name, by default Group. After the clause the original range variables are no longer in scope. Only the key (named, or inferred from the expression) and the Into names are, which is why Order By Genre after the grouping works and Order By b.Year does not.

Group By with an element selector (Group b.Title By ...) makes Group a sequence of titles instead of books. The key can be an expression (b.Year \ 100 + 1, the century) or a composite built with an anonymous type; for a composite key the members that must take part in the comparison are marked Key.

Aggregate is the one clause that can start a query. It runs at once and returns either the value of its single aggregate, or a record whose members are named after the functions when there are several. It also works as a clause inside a larger query, introducing a range variable that holds the aggregate for each element (with Let here).

Option Strict On

Public Class Book
    Public Property Title As String
    Public Property Genre As String
    Public Property Year As Integer
    Public Property Pages As Integer
    Public Sub New(title As String, genre As String, year As Integer, pages As Integer)
        Me.Title = title
        Me.Genre = genre
        Me.Year = year
        Me.Pages = pages
    End Sub
End Class

Module Program
    Sub Main()
        Dim books As New List(Of Book) From {
            New Book("Dune", "Science fiction", 1965, 412),
            New Book("Neuromancer", "Science fiction", 1984, 271),
            New Book("Hyperion", "Science fiction", 1989, 482),
            New Book("Emma", "Novel", 1815, 474),
            New Book("Ulysses", "Novel", 1922, 730),
            New Book("Walden", "Essay", 1854, 352)
        }

        Console.WriteLine("-- Group By ... Into: Key, Group and aggregate functions")
        Dim byGenre = From b In books
                      Group By b.Genre Into Count(), TotalPages = Sum(b.Pages), Newest = Max(b.Year), Titles = Group
                      Order By Genre
        For Each g In byGenre
            Console.WriteLine(g.Genre & ": " & g.Count & " book(s), " & g.TotalPages & " pages, newest " & g.Newest &
                              ", first = " & g.Titles.First().Title)
        Next

        Console.WriteLine("-- Group By with an explicit key name and element selector")
        Dim byCentury = From b In books
                        Group b.Title By Century = b.Year \ 100 + 1 Into Group
                        Order By Century
                        Select Century, Titles = String.Join(", ", Group)
        For Each c In byCentury
            Console.WriteLine("century " & c.Century & ": " & c.Titles)
        Next

        Console.WriteLine("-- Group By a composite key")
        Dim grouped = From b In books
                      Group By Key = New With {Key b.Genre, Key .Old = b.Year < 1950} Into Group
                      Order By Key.Genre
        For Each g In grouped
            Console.WriteLine(g.Key.Genre & " old=" & g.Key.Old & " -> " & g.Group.Count())
        Next

        Console.WriteLine("-- Aggregate ... Into at the start of a query returns one value")
        Dim stats = Aggregate b In books
                    Where b.Genre = "Science fiction"
                    Into Count(), Sum(b.Pages), Average(b.Pages), Min(b.Year), Max(b.Year), Any(), All(b.Pages > 200)
        Console.WriteLine(stats.Count & " " & stats.Sum & " " & stats.Average.ToString("F1") & " " & stats.Min & " " & stats.Max &
                          " " & stats.Any & " " & stats.All)

        Console.WriteLine("-- Aggregate as a clause inside a query")
        Dim perGenre = From g In (From b In books Select b.Genre Distinct)
                       Let pages = Aggregate b In books Where b.Genre = g Into Sum(b.Pages)
                       Select g, pages
        For Each p In perGenre
            Console.WriteLine(p.g & " = " & p.pages)
        Next
    End Sub
End Module
-- Group By ... Into: Key, Group and aggregate functions
Essay: 1 book(s), 352 pages, newest 1854, first = Walden
Novel: 2 book(s), 1204 pages, newest 1922, first = Emma
Science fiction: 3 book(s), 1165 pages, newest 1989, first = Dune
-- Group By with an explicit key name and element selector
century 19: Emma, Walden
century 20: Dune, Neuromancer, Hyperion, Ulysses
-- Group By a composite key
Essay old=True -> 1
Novel old=True -> 2
Science fiction old=False -> 3
-- Aggregate ... Into at the start of a query returns one value
3 1165 388.3 1965 1989 True True
-- Aggregate as a clause inside a query
Science fiction = 1165
Novel = 1204
Essay = 352

Notes on the results above:

  • The group order of Group By follows the first appearance of each key in the source, which is why the query sorts by Genre afterwards.

  • The Average of Integer values is a Double. Sum of Integer values is computed with checked arithmetic and throws OverflowException on overflow whatever the project’s integer-overflow setting is (the last program on this page shows it); sum a Long projection (Sum(Function(x) CLng(x))) when the total can be large. The -removeintchecks option of the Visual Basic compiler affects only the + operator and its relatives (see Type Conversions).

  • Aggregate with Any() and All(...) is allowed in the Into list; they return Boolean.

  • Grouping by a composite key needs Key on the members of the anonymous type (see the section on anonymous types), or every element ends up in its own group because anonymous instances without Key compare by reference.

Let and several From clauses

Let computes a value once per element and gives it a name that later clauses can use, which both avoids repeated work and keeps queries readable. A second From clause iterates a second sequence for each element of the first: if the second sequence depends on the first range variable it flattens nested collections (this is SelectMany), and if it does not it is a cross join. Range variables that are declared by a clause stay in scope until a Select, Group By or Aggregate …​ Into ends their scope, and a name cannot be declared twice (BC30978).

Option Strict On

Module Program
    Sub Main()
        Dim sentences As String() = {"the quick brown fox", "jumps over", "the lazy dog"}

        Console.WriteLine("-- Let introduces a range variable computed once per element")
        Dim q1 = From s In sentences
                 Let words = s.Split(" "c)
                 Let count = words.Length
                 Where count > 2
                 Select s, count, First = words(0)
        For Each r In q1
            Console.WriteLine(r.s & " | " & r.count & " | " & r.First)
        Next

        Console.WriteLine("-- Several From clauses: Select Many (flatten)")
        Dim q2 = From s In sentences
                 From w In s.Split(" "c)
                 Where w.Length > 3
                 Select w
        Console.WriteLine(String.Join(",", q2))

        Console.WriteLine("-- Several From clauses without a relation: cross join")
        Dim q3 = From x In {1, 2, 3}
                 From y In {"a", "b"}
                 Select x & y
        Console.WriteLine(String.Join(",", q3))

        Console.WriteLine("-- Range variable scope: only the last Select/Group By/Into survives")
        Dim q4 = From s In sentences
                 Select Upper = s.ToUpper()
                 Where Upper.StartsWith("THE")
                 Select Upper.Length
        Console.WriteLine(String.Join(",", q4))

        Console.WriteLine("-- Take/Skip as query operators and the Distinct keyword on an anonymous type")
        Dim q5 = From w In (From s In sentences From x In s.Split(" "c) Select x)
                 Select w.Length, First = w(0) Distinct
                 Order By Length
        For Each r In q5
            Console.WriteLine(r.Length & r.First)
        Next
    End Sub
End Module
-- Let introduces a range variable computed once per element
the quick brown fox | 4 | the
the lazy dog | 3 | the
-- Several From clauses: Select Many (flatten)
quick,brown,jumps,over,lazy
-- Several From clauses without a relation: cross join
1a,1b,2a,2b,3a,3b
-- Range variable scope: only the last Select/Group By/Into survives
19,12
-- Take/Skip as query operators and the Distinct keyword on an anonymous type
3t
3f
3d
4o
4l
5q
5b
5j

How the compiler translates a query

The translation follows the query expression pattern. For each clause the compiler binds a method by name to the current source, with ordinary overload resolution, so any type that has methods called Where, Select and the others participates, whether they are instance methods or extension methods. The next program is a type that only records which methods it is asked to run. It shows the exact translation of eleven queries.

Option Strict On

Module Recorder
    Public ReadOnly Calls As New List(Of String)
End Module

' A type that implements the query pattern: it only records which methods the compiler calls
Public Class Spy(Of T)
    Public Function [Where](predicate As Func(Of T, Boolean)) As Spy(Of T)
        Recorder.Calls.Add("Where")
        Return Me
    End Function

    Public Function [Select](Of TResult)(selector As Func(Of T, TResult)) As Spy(Of TResult)
        Recorder.Calls.Add("Select")
        Return New Spy(Of TResult)
    End Function

    Public Function OrderBy(Of TKey)(keySelector As Func(Of T, TKey)) As Spy(Of T)
        Recorder.Calls.Add("OrderBy")
        Return Me
    End Function

    Public Function OrderByDescending(Of TKey)(keySelector As Func(Of T, TKey)) As Spy(Of T)
        Recorder.Calls.Add("OrderByDescending")
        Return Me
    End Function

    Public Function ThenBy(Of TKey)(keySelector As Func(Of T, TKey)) As Spy(Of T)
        Recorder.Calls.Add("ThenBy")
        Return Me
    End Function

    Public Function ThenByDescending(Of TKey)(keySelector As Func(Of T, TKey)) As Spy(Of T)
        Recorder.Calls.Add("ThenByDescending")
        Return Me
    End Function

    Public Function Skip(count As Integer) As Spy(Of T)
        Recorder.Calls.Add("Skip")
        Return Me
    End Function

    Public Function Take(count As Integer) As Spy(Of T)
        Recorder.Calls.Add("Take")
        Return Me
    End Function

    Public Function SkipWhile(predicate As Func(Of T, Boolean)) As Spy(Of T)
        Recorder.Calls.Add("SkipWhile")
        Return Me
    End Function

    Public Function TakeWhile(predicate As Func(Of T, Boolean)) As Spy(Of T)
        Recorder.Calls.Add("TakeWhile")
        Return Me
    End Function

    Public Function Distinct() As Spy(Of T)
        Recorder.Calls.Add("Distinct")
        Return Me
    End Function

    Public Function SelectMany(Of TCollection, TResult)(collectionSelector As Func(Of T, Spy(Of TCollection)),
                                                       resultSelector As Func(Of T, TCollection, TResult)) As Spy(Of TResult)
        Recorder.Calls.Add("SelectMany")
        Return New Spy(Of TResult)
    End Function

    Public Function Join(Of TInner, TKey, TResult)(inner As Spy(Of TInner), outerKeySelector As Func(Of T, TKey),
                                                  innerKeySelector As Func(Of TInner, TKey),
                                                  resultSelector As Func(Of T, TInner, TResult)) As Spy(Of TResult)
        Recorder.Calls.Add("Join")
        Return New Spy(Of TResult)
    End Function

    Public Function GroupJoin(Of TInner, TKey, TResult)(inner As Spy(Of TInner), outerKeySelector As Func(Of T, TKey),
                                                       innerKeySelector As Func(Of TInner, TKey),
                                                       resultSelector As Func(Of T, Spy(Of TInner), TResult)) As Spy(Of TResult)
        Recorder.Calls.Add("GroupJoin")
        Return New Spy(Of TResult)
    End Function

    Public Function GroupBy(Of TKey, TResult)(keySelector As Func(Of T, TKey),
                                             resultSelector As Func(Of TKey, Spy(Of T), TResult)) As Spy(Of TResult)
        Recorder.Calls.Add("GroupBy")
        Return New Spy(Of TResult)
    End Function

    Public Function Count() As Integer
        Recorder.Calls.Add("Count")
        Return 0
    End Function

    Public Function Sum(selector As Func(Of T, Integer)) As Integer
        Recorder.Calls.Add("Sum")
        Return 0
    End Function
End Class

Module Program
    Sub Report(label As String)
        Console.WriteLine(label.PadRight(28) & String.Join(" > ", Recorder.Calls))
        Recorder.Calls.Clear()
    End Sub

    Sub Main()
        Dim numbers As New Spy(Of Integer)
        Dim words As New Spy(Of String)

        Dim q1 = From n In numbers Where n > 1 Select n * 2
        Report("Where, Select")

        Dim q2 = From n In numbers Order By n, n * 2 Descending Select n
        Report("Order By a, b Descending")

        Dim q3 = From n In numbers Let sq = n * n Where sq > 4 Select sq
        Report("Let, Where, Select")

        Dim q4 = From n In numbers From w In words Select n & w
        Report("two From clauses")

        Dim q5 = From n In numbers Join w In words On n Equals w.Length Select w
        Report("Join")

        Dim q6 = From n In numbers Group Join w In words On n Equals w.Length Into Group Select n
        Report("Group Join")

        Dim q7 = From n In numbers Group By k = n Mod 2 Into Group
        Report("Group By ... Into Group")

        Dim q8 = From n In numbers Select n Distinct Skip 1 Take 2
        Report("Select Distinct Skip Take")

        Dim q9 = From n In numbers Skip While n < 3 Take While n < 9 Select n
        Report("Skip While, Take While")

        Dim q10 = Aggregate n In numbers Where n > 0 Into Count(), Sum(n)
        Report("Aggregate Into Count Sum")

        Dim q11 = From n In numbers Where n > 0
        Report("Where only (no Select)")
    End Sub
End Module
Where, Select               Where > Select
Order By a, b Descending    OrderBy > ThenByDescending > Select
Let, Where, Select          Select > Where > Select
two From clauses            SelectMany
Join                        Join
Group Join                  GroupJoin > Select
Group By ... Into Group     GroupBy
Select Distinct Skip Take   Select > Distinct > Skip > Take
Skip While, Take While      SkipWhile > TakeWhile > Select
Aggregate Into Count Sum    Where > Count > Sum
Where only (no Select)      Where

What the output teaches:

  • Order By a, b Descending is one OrderBy followed by ThenByDescending; the first key uses OrderBy, never ThenBy.

  • A Select that just projects the final range variable is folded into the previous operator when one can carry it (Join and SelectMany take a result selector), which is why those queries show no separate Select.

  • Let is a Select that carries the old range variables along with the new one in a hidden anonymous type; this is the reason Let appears as Select in the trace.

  • Group Join ... Into Group is GroupJoin followed by Select that exposes the group; Group By is a single GroupBy call.

  • Aggregate applies the filtering clauses once and then calls each aggregate method (Count, Sum) separately on the filtered query object. Over a lazy source every function enumerates the filter again: the last program on this page counts eight filter calls for four elements and two aggregate functions.

  • A query with only a Where has no Select at all; its type is the source’s element type.

The same pattern is why the same query text can be a pipeline of delegates over an array or an expression tree handed to a database provider: the lookup finds Enumerable.Where(Func(Of T, Boolean)) for the former and Queryable.Where(Expression(Of Func(Of T, Boolean))) for the latter.

Method syntax and the operators without a keyword

Method syntax is the ordinary extension-method chain with lambdas. It is not a different feature, and it can be mixed with query syntax in both directions: a query can start from a method call, and a method call can be applied to a parenthesised query. Many operators have no query keyword and are only available as methods: MaxBy, MinBy, DistinctBy, Chunk, Zip, ToLookup, ToDictionary, ToHashSet, Union, Intersect, Except, Concat, Append, Prepend, Reverse, Order, OrderDescending, ElementAt, Contains, SequenceEqual, and the newest additions CountBy, AggregateBy and Index, which were added to System.Linq in .NET 9. All of them work from Visual Basic exactly like any other extension method.

Option Strict On

Public Class Book
    Public Property Title As String
    Public Property Author As String
    Public Property Genre As String
    Public Property Year As Integer
    Public Sub New(title As String, author As String, genre As String, year As Integer)
        Me.Title = title
        Me.Author = author
        Me.Genre = genre
        Me.Year = year
    End Sub
End Class

Module Program
    Sub Main()
        Dim books As New List(Of Book) From {
            New Book("Emma", "Jane Austen", "Novel", 1815),
            New Book("Persuasion", "Jane Austen", "Novel", 1817),
            New Book("Dune", "Frank Herbert", "Science fiction", 1965),
            New Book("Neuromancer", "William Gibson", "Science fiction", 1984),
            New Book("Walden", "Henry David Thoreau", "Essay", 1854)
        }

        ' Operators that have no query keyword exist only as methods
        Console.WriteLine("MaxBy      : " & books.MaxBy(Function(b) b.Year).Title)
        Console.WriteLine("DistinctBy : " & String.Join(",", books.DistinctBy(Function(b) b.Genre).Select(Function(b) b.Title)))
        Console.WriteLine("Chunk      : " & String.Join(" | ", books.Chunk(2).Select(Function(c) String.Join(",", c.Select(Function(b) b.Title)))))
        Console.WriteLine("Zip        : " & String.Join(",", books.Select(Function(b) b.Title).Zip({1, 2, 3}, Function(t, n) n & t)))
        Console.WriteLine("ToLookup   : " & books.ToLookup(Function(b) b.Genre)("Novel").Count())
        Console.WriteLine("ToDictionary: " & books.ToDictionary(Function(b) b.Title, Function(b) b.Year)("Dune"))
        Console.WriteLine("Order      : " & String.Join(",", {3, 1, 2}.Order()))
        Console.WriteLine("OrderDesc  : " & String.Join(",", {3, 1, 2}.OrderDescending()))
        Console.WriteLine("Append     : " & String.Join(",", {1, 2}.Append(3).Prepend(0)))
        Console.WriteLine("Contains   : " & books.Select(Function(b) b.Genre).Contains("Essay"))
        Console.WriteLine("First?     : " & If(books.FirstOrDefault(Function(b) b.Year > 2000) Is Nothing, "none", "found"))
        Console.WriteLine("ElementAt  : " & books.ElementAt(2).Title)
        Console.WriteLine("Aggregate  : " & books.Aggregate(0, Function(acc, b) acc + b.Title.Length))
        Console.WriteLine("Union      : " & String.Join(",", {1, 2, 3}.Union({3, 4})))
        Console.WriteLine("Except     : " & String.Join(",", {1, 2, 3}.Except({3, 4})))
        Console.WriteLine("Intersect  : " & String.Join(",", {1, 2, 3}.Intersect({3, 4})))
        Console.WriteLine("Reverse    : " & String.Join(",", {1, 2, 3}.Reverse()))

        ' Added to System.Linq after the language feature set was frozen
        Console.WriteLine("CountBy    : " & String.Join(",", books.CountBy(Function(b) b.Genre).Select(Function(kv) kv.Key & "=" & kv.Value)))
        Console.WriteLine("AggregateBy: " & String.Join(",", books.AggregateBy(Function(b) b.Genre, 0, Function(acc, b) acc + b.Year).Select(Function(kv) kv.Key & "=" & kv.Value)))
        Console.WriteLine("Index      : " & String.Join(",", {"a", "b"}.Index().Select(Function(t) t.Index & t.Item)))

        ' Query keywords and method calls mix freely
        Dim mixed = From g In books.GroupBy(Function(b) b.Genre)
                    Where g.Count() > 1
                    Select g.Key, Newest = g.Max(Function(b) b.Year)
        For Each m In mixed
            Console.WriteLine(m.Key & " " & m.Newest)
        Next
    End Sub
End Module
MaxBy      : Neuromancer
DistinctBy : Emma,Dune,Walden
Chunk      : Emma,Persuasion | Dune,Neuromancer | Walden
Zip        : 1Emma,2Persuasion,3Dune
ToLookup   : 2
ToDictionary: 1965
Order      : 1,2,3
OrderDesc  : 3,2,1
Append     : 0,1,2,3
Contains   : True
First?     : none
ElementAt  : Dune
Aggregate  : 35
Union      : 1,2,3,4
Except     : 1,2
Intersect  : 3
Reverse    : 3,2,1
CountBy    : Novel=2,Science fiction=2,Essay=1
AggregateBy: Novel=3632,Science fiction=3949,Essay=1854
Index      : 0a,1b
Novel 1817
Science fiction 1984

Two Visual Basic details about method syntax:

  • A lambda for the operators is a Function(x) ... expression with a single expression as its body; to use statements, write a multi-line Function ... End Function (a statement lambda), which is fine for IEnumerable operators and rejected for expression trees (BC36675).

  • A method chain may wrap lines after the dot (books. at the end of the line), as the first example does, because the dot and the parenthesis are implicit continuation points (see Delegates and Lambda Expressions).

Anonymous types in projections

Select b.Title, b.Year produces elements of an anonymous type. In a query, the members that the compiler creates from the projection are Key properties: they are read-only and they take part in equality and hash codes. The same projection written as New With {b.Title, b.Year} in a method call creates non-key properties, which is why the comparison at the start of this page needed Key in the method form to type-check (without it, BC36754 reports that the two anonymous types differ). The consequences are:

  • Two projected rows with equal values are equal, so Distinct, Group By with an anonymous key and Join on a composite key work as expected for query-syntax projections.

  • Anonymous types declared without Key use reference equality for those members, so Distinct removes nothing and a composite Group By key creates one group per element.

  • The members cannot be assigned after construction (Key members are read-only) and their types are not nameable, so a function that returns them must return IEnumerable(Of Object), a named class or a tuple.

Option Strict On

Module Program
    Sub Main()
        Dim genres As String() = {"Novel", "Essay", "Novel", "Novel", "Essay"}

        ' Query projections have Key members: equal values are equal
        Console.WriteLine((From g In genres Select g, Len = g.Length Distinct).Count())

        ' New With without Key: the members take no part in equality
        Dim plain = genres.Select(Function(g) New With {.Genre = g}).Distinct().Count()
        Dim keyed = genres.Select(Function(g) New With {Key .Genre = g}).Distinct().Count()
        Console.WriteLine("plain Distinct: " & plain & ", keyed Distinct: " & keyed)

        ' Group By with a composite key built without and with Key
        Dim plainGroups = From g In genres Group By k = New With {.Genre = g} Into Group
        Dim keyedGroups = From g In genres Group By k = New With {Key .Genre = g} Into Group
        Console.WriteLine("plain groups: " & plainGroups.Count() & ", keyed groups: " & keyedGroups.Count())
    End Sub
End Module
2
plain Distinct: 5, keyed Distinct: 2
plain groups: 5, keyed groups: 2

Source: Anonymous Types

Tuples are an alternative that has a name: Select (Member:=l.Member, b.Title, daysLate) (see the shelf report at the end of this page) gives ValueTuple elements with named members. The full comparison, including the equality rules, is on Tuples and Anonymous Types.

Reusable queries

A query that is useful in several places becomes a function. The function should return IEnumerable(Of T), not a materialised list, so that callers can keep composing on it, and the function should take the source as a parameter instead of reading a field. A query returned from a function is still unexecuted. The classic extension-method form turns it into a query operator of its own; an Iterator Function can run code between elements and stays lazy as well (see Iterators and Yield and Extension Methods and Partial Methods).

Option Strict On

Imports System.Runtime.CompilerServices

Public Class Book
    Public Property Title As String
    Public Property Year As Integer
    Public Sub New(title As String, year As Integer)
        Me.Title = title
        Me.Year = year
    End Sub
End Class

Module BookQueries
    ' A reusable query operator is an extension method over IEnumerable(Of T)
    <Extension>
    Function Published(source As IEnumerable(Of Book), after As Integer) As IEnumerable(Of Book)
        Return From b In source Where b.Year > after
    End Function

    <Extension>
    Function Page(Of T)(source As IEnumerable(Of T), pageNumber As Integer, size As Integer) As IEnumerable(Of T)
        Return From x In source Skip (pageNumber - 1) * size Take size
    End Function

    ' An iterator version can run code between elements and stays lazy
    Iterator Function Numbered(source As IEnumerable(Of Book)) As IEnumerable(Of String)
        Dim i As Integer = 0
        For Each b In source
            i += 1
            Yield i & ". " & b.Title
        Next
    End Function
End Module

Module Program
    Sub Main()
        Dim books As New List(Of Book) From {
            New Book("Emma", 1815), New Book("Dune", 1965), New Book("Neuromancer", 1984),
            New Book("Hyperion", 1989), New Book("Ulysses", 1922)
        }

        Dim page2 = books.Published(1900).Page(2, 2)
        For Each line In Numbered(page2)
            Console.WriteLine(line)
        Next

        ' A function that returns a query returns the description, not the results
        Dim lazy = books.Published(1950)
        books.Add(New Book("Solaris", 1961))
        Console.WriteLine(lazy.Count())
    End Sub
End Module
1. Hyperion
2. Ulysses
4

Comparison, ordering and Option Compare

Strings in a Where clause are compared with the normal = operator, so Option Compare Text makes b.Genre = "NOVEL" match "Novel". The key comparisons that the operators perform internally are different: Group By, Join and Distinct call the default equality of the key type, which for String is ordinal and case-sensitive, and the Equals keyword of Join is therefore not affected by Option Compare either. Order By uses the default comparer of the key type (for strings, the comparison of the current culture); it needs a key that implements IComparable, and a key that does not fails when the query runs, not when it is compiled. To control string comparison in grouping, ordering or joining, call the method form with an explicit StringComparer (books.GroupBy(Function(b) b.Genre, StringComparer.OrdinalIgnoreCase)).

Option Strict On
Option Compare Text

Public Class Book
    Public Property Title As String
    Public Property Genre As String
    Public Sub New(title As String, genre As String)
        Me.Title = title
        Me.Genre = genre
    End Sub
End Class

Module Program
    Sub Main()
        Dim books As New List(Of Book) From {
            New Book("Emma", "Novel"), New Book("Ulysses", "novel"), New Book("Dune", "Science fiction")
        }

        ' The = operator honours Option Compare Text ...
        Console.WriteLine("Where  : " & (From b In books Where b.Genre = "NOVEL" Select b.Title).Count())

        ' ... but the key comparisons of Group By, Join and Distinct use the default .NET equality
        Console.WriteLine("Group  : " & (From b In books Group By b.Genre Into Group).Count())
        Console.WriteLine("Distinct: " & (From b In books Select b.Genre Distinct).Count())
        Dim lookup = {"NOVEL"}
        Console.WriteLine("Join   : " & (From b In books Join l In lookup On b.Genre Equals l Select b.Title).Count())

        ' Order By needs keys that can be compared
        Try
            Dim q = From b In books Order By b Select b.Title
            Console.WriteLine(String.Join(",", q))
        Catch ex As InvalidOperationException
            Console.WriteLine("Order By object: " & ex.GetType().Name & ": " & ex.Message)
        Catch ex As ArgumentException
            Console.WriteLine("Order By object: " & ex.GetType().Name & ": " & ex.Message)
        End Try
    End Sub
End Module
Where  : 2
Group  : 3
Distinct: 3
Join   : 0
Order By object: InvalidOperationException: Failed to compare two elements in the array.

LINQ to DataSet

A DataTable is not generic and does not implement IEnumerable(Of DataRow); the last line of the program shows that. The adapter is AsEnumerable() (from System.Data.DataTableExtensions, in the shared framework), which exposes the rows as IEnumerable(Of DataRow). Columns are read with Field(Of T)(name), which converts to the requested type and maps DBNull to Nothing for reference types and nullable value types, while a direct cast of DBNull fails (and Field(Of Integer) on a DBNull throws). CopyToDataTable() goes the other way, from a query over rows back to a table. LINQ to DataSet is plain LINQ to Objects over rows; no SQL is generated, so it fits in-memory data, data that has already been fetched with a DataAdapter, and tests. See Data Access: ADO.NET and EF Core for the connection and adapter side.

Option Strict On

Imports System.Data

Module Program
    Sub Main()
        Dim loans As New DataTable("Loans")
        loans.Columns.Add("Isbn", GetType(String))
        loans.Columns.Add("Member", GetType(String))
        loans.Columns.Add("DaysLate", GetType(Integer))
        loans.Columns.Add("Note", GetType(String))
        loans.Rows.Add("9780441172719", "ana", 0, DBNull.Value)
        loans.Rows.Add("9780451524935", "ben", 12, "reminder sent")
        loans.Rows.Add("9780141439518", "ana", 3, DBNull.Value)
        loans.Rows.Add("9780261103573", "cleo", 30, "final notice")

        ' AsEnumerable() adapts the rows to IEnumerable(Of DataRow); Field(Of T) converts and maps DBNull
        Dim late = From r In loans.AsEnumerable()
                   Where r.Field(Of Integer)("DaysLate") > 2
                   Order By r.Field(Of Integer)("DaysLate") Descending
                   Select Member = r.Field(Of String)("Member"),
                          Days = r.Field(Of Integer)("DaysLate"),
                          Note = r.Field(Of String)("Note")
        For Each l In late
            Console.WriteLine(l.Member & " " & l.Days & " " & If(l.Note, "(no note)"))
        Next

        Dim perMember = From r In loans.AsEnumerable()
                        Group By Member = r.Field(Of String)("Member") Into Total = Sum(r.Field(Of Integer)("DaysLate"))
        For Each p In perMember
            Console.WriteLine(p.Member & ": " & p.Total)
        Next

        ' CopyToDataTable turns a query over DataRow back into a DataTable
        Dim lateTable As DataTable = (From r In loans.AsEnumerable()
                                      Where r.Field(Of Integer)("DaysLate") > 10
                                      Select r).CopyToDataTable()
        Console.WriteLine("copied rows: " & lateTable.Rows.Count)

        ' A typed read of a DBNull column throws; Field(Of String) maps DBNull to Nothing
        Try
            Dim bad As String = CStr(loans.Rows(0)("Note"))
            Console.WriteLine(bad)
        Catch ex As InvalidCastException
            Console.WriteLine("CStr(DBNull): " & ex.GetType().Name)
        End Try
        Console.WriteLine("Field(Of String): " & If(loans.Rows(0).Field(Of String)("Note"), "Nothing"))

        ' Without AsEnumerable the table itself is not queryable
        Console.WriteLine(GetType(IEnumerable(Of DataRow)).IsAssignableFrom(GetType(DataTable)))
    End Sub
End Module
cleo 30 final notice
ben 12 reminder sent
ana 3 (no note)
ana: 3
ben: 12
cleo: 30
copied rows: 2
CStr(DBNull): InvalidCastException
Field(Of String): Nothing
False

Expression trees and IQueryable

A lambda is converted to one of two things depending on the target type. Assigned to a delegate type such as Func(Of Book, Boolean) it becomes compiled code. Assigned to Expression(Of Func(Of Book, Boolean)) it becomes a data structure that describes the code (an expression tree) which a provider can inspect and translate, for example to SQL. Queries over an IQueryable(Of T) pick the Queryable operators, whose parameters are expression trees, so the query is captured as one tree and executed by the provider when it is enumerated. Entity Framework Core works this way; see Data Access: ADO.NET and EF Core.

The restrictions of expression trees in Visual Basic are stricter than those of delegates: only single-expression lambdas (Function(b) b.Year > 1900) can be converted, statement lambdas cannot (BC36675, shown below), and the tree supports the operators that System.Linq.Expressions has nodes for. The next program shows the two conversions, the tree of a query over AsQueryable() (a real provider such as EF Core would translate it to SQL; the in-memory EnumerableQuery provider simply compiles it), and a tree built by hand.

Option Strict On

Imports System.Linq.Expressions

Public Class Book
    Public Property Title As String
    Public Property Year As Integer
    Public Sub New(title As String, year As Integer)
        Me.Title = title
        Me.Year = year
    End Sub
End Class

Module Program
    Sub Main()
        Dim books As New List(Of Book) From {New Book("Emma", 1815), New Book("Dune", 1965)}

        ' A lambda converted to a delegate type is code; converted to Expression(Of ...) it is data
        Dim asDelegate As Func(Of Book, Boolean) = Function(b) b.Year > 1900
        Dim asTree As Expression(Of Func(Of Book, Boolean)) = Function(b) b.Year > 1900
        Console.WriteLine(asTree)
        Console.WriteLine(asTree.Body.NodeType.ToString() & " / " & asTree.Parameters(0).Name)
        Console.WriteLine(asTree.Compile()(books(1)) & " " & asDelegate(books(0)))

        ' IEnumerable(Of T) queries take delegates; IQueryable(Of T) queries take expression trees
        Dim source As IQueryable(Of Book) = books.AsQueryable()
        Dim q = From b In source Where b.Year > 1900 Order By b.Title Select b.Title
        Console.WriteLine(q.Expression)
        Console.WriteLine(q.Provider.GetType().Name)
        Console.WriteLine(String.Join(",", q))

        ' Building a tree by hand: b => b.Title = "Dune"
        Dim p = Expression.Parameter(GetType(Book), "b")
        Dim body = Expression.Equal(Expression.Property(p, "Title"), Expression.Constant("Dune"))
        Dim lambda = Expression.Lambda(Of Func(Of Book, Boolean))(body, p)
        Console.WriteLine(lambda)
        Console.WriteLine(books.AsQueryable().Count(lambda))
    End Sub
End Module
b => (b.Year > 1900)
GreaterThan / b
True False
System.Collections.Generic.List`1[App.Book].Where(b => (b.Year > 1900)).OrderBy(b => b.Title).Select(b => b.Title)
EnumerableQuery`1
Dune
b => (b.Title == "Dune")
1

PLINQ

AsParallel() turns an IEnumerable(Of T) into a ParallelQuery(Of T), and the same query clauses then bind to the operators of System.Linq.ParallelEnumerable, which partition the source across threads. Query syntax works unchanged, Aggregate accepts a parallel source, and AsOrdered() restores the source order at a cost. Parallelism pays off for CPU-bound work on large sources, and not for small sources or cheap lambdas, where partitioning costs more than it saves. Delegates inside the query must be thread safe and free of side effects; to collect results use ForAll with a concurrent collection, or enumerate the result. The threading primitives are covered on Threads, Parallelism and Synchronization.

Option Strict On

Module Program
    Sub Main()
        Dim numbers = Enumerable.Range(1, 20)

        ' AsParallel: the query runs on several threads, so the result order is not defined...
        Dim unordered = From n In numbers.AsParallel()
                        Where n Mod 3 = 0
                        Select n * n
        Console.WriteLine("unordered sorted: " & String.Join(",", unordered.OrderBy(Function(x) x)))

        ' ...unless AsOrdered is used
        Dim ordered = From n In numbers.AsParallel().AsOrdered()
                      Where n Mod 3 = 0
                      Select n * n
        Console.WriteLine("AsOrdered       : " & String.Join(",", ordered))

        ' Aggregates over a parallel source
        Dim stats = Aggregate n In numbers.AsParallel() Into Sum(n), Count()
        Console.WriteLine("sum=" & stats.Sum & " count=" & stats.Count)

        ' Do not mutate shared state from a parallel query
        Dim sink As New Concurrent.ConcurrentBag(Of Integer)
        numbers.AsParallel().ForAll(Sub(n) sink.Add(n))
        Console.WriteLine("collected: " & sink.Count)
    End Sub
End Module
unordered sorted: 9,36,81,144,225,324
AsOrdered       : 9,36,81,144,225,324
sum=210 count=20
collected: 20

A shelf report

A last example puts the pieces together on the shelf scenario: overdue loans joined with their books, a Let for the days late, an Order By, a tuple projection with a named member, a grouped summary that uses the conditional form of Count, and a query whose Select creates objects of an ordinary class.

Option Strict On

Public Enum LoanState
    Active = 1
    Returned = 2
    Overdue = 3
End Enum

Public Class Book
    Public Property Isbn As String
    Public Property Title As String
    Public Sub New(isbn As String, title As String)
        Me.Isbn = isbn
        Me.Title = title
    End Sub
End Class

Public Class Loan
    Public Property Isbn As String
    Public Property Member As String
    Public Property DueOn As Date
    Public Property State As LoanState
    Public Sub New(isbn As String, member As String, dueOn As Date, state As LoanState)
        Me.Isbn = isbn
        Me.Member = member
        Me.DueOn = dueOn
        Me.State = state
    End Sub
End Class

Module Program
    Sub Main()
        Dim today As New Date(2026, 10, 19)
        Dim books As New List(Of Book) From {
            New Book("9780441172719", "Dune"), New Book("9780451524935", "1984"), New Book("9780141439518", "Emma")
        }
        Dim loans As New List(Of Loan) From {
            New Loan("9780441172719", "ana", New Date(2026, 9, 1), LoanState.Overdue),
            New Loan("9780451524935", "ben", New Date(2026, 10, 1), LoanState.Overdue),
            New Loan("9780141439518", "ana", New Date(2026, 10, 30), LoanState.Active),
            New Loan("9780441172719", "ben", New Date(2026, 5, 1), LoanState.Returned)
        }

        Dim report = From l In loans
                     Where l.State = LoanState.Overdue
                     Join b In books On l.Isbn Equals b.Isbn
                     Let daysLate = (today - l.DueOn).Days
                     Order By daysLate Descending
                     Select (Member:=l.Member, b.Title, daysLate)

        For Each row In report
            Console.WriteLine(row.Member & " has " & row.Title & " " & row.daysLate & " days late")
        Next

        Dim perMember = From l In loans
                        Group By l.Member Into Total = Count(), Open = Count(l.State <> LoanState.Returned)
                        Order By Open Descending, Member
        For Each m In perMember
            Console.WriteLine(m.Member & ": " & m.Total & " loans, " & m.Open & " open")
        Next

        ' Query results can be projected into ordinary objects too
        Dim overdueBooks As List(Of Book) = (From b In books
                                             Join l In loans On b.Isbn Equals l.Isbn
                                             Where l.State = LoanState.Overdue
                                             Select New Book(b.Isbn, b.Title.ToUpper())).ToList()
        Console.WriteLine(String.Join(", ", From b In overdueBooks Select b.Title))
    End Sub
End Module
ana has Dune 48 days late
ben has 1984 18 days late
ana: 2 loans, 2 open
ben: 2 loans, 1 open
DUNE, 1984

Differences from C#

Topic Visual Basic C#

Query clauses

From, Where, Select, Order By, Join, Group Join, Group By ... Into, Let, Distinct, Skip, Take, Skip While, Take While, Aggregate ... Into

from, where, select, orderby, join ... on ... equals, join ... into, group ... by, into, let; the rest only as methods

Clause order

nearly free; Select may come first and may repeat

from first and select or group last

Group result

Into list with aggregates and Group

IGrouping(Of TKey, T) elements (with into)

Aggregates

Aggregate x In s Into Sum(x.A), Count() clause

method calls

Range variable of a projection

members of Select are range variables for later clauses

not possible, use into

Ordering keys

Order By a, b Descending

orderby a, b descending

Join equality

Equals, several pairs joined with And

equals, composite keys as anonymous types

Anonymous type from a query projection

Key properties in query syntax; non-Key with New With unless marked

value-equality read-only properties always

Statement lambdas in expression trees

BC36675

not allowed either (CS0834), expression bodies only

Tuples in projections

named tuple members, no deconstruction in Dim

deconstruction available

XML in queries

XML literals and axis properties inside queries

LINQ to XML method calls only

Error and warning reference

The messages below were produced by the SDK. The first program contains one statement for each case that compiles far enough to reach semantic analysis; the messages are listed in the order of the statements. In order: the ByRef parameter in the Count procedure (line 18), a late-bound Object source (27), an Integer source (28), join keys of unrelated types (29), a join key that mixes both sides (30), an unknown aggregate Foo (31, which produces two messages), a query assigned to a List(Of String) (32), a range variable declared twice (33) and a statement lambda converted to an expression tree (34).

Option Strict On

Imports System.Linq.Expressions

Public Class Book
    Public Property Id As Integer
    Public Property Title As String
    Public Property Year As Integer
End Class

Public Class Author
    Public Property Id As Integer
    Public Property Name As String
End Class

Module Program
    Sub Count(ByRef n As Integer, books As List(Of Book))
        Dim q = From b In books Where b.Year > n Select b
    End Sub

    Sub Main()
        Dim books As New List(Of Book)
        Dim authors As New List(Of Author)
        Dim obj As Object = books
        Dim n As Integer = 42

        Dim a = From x In obj Select x
        Dim b = From x In n Select x
        Dim c = From bk In books Join au In authors On bk.Id Equals au.Name Select bk
        Dim d = From bk In books Join au In authors On bk.Id + au.Id Equals au.Id Select bk
        Dim e = From bk In books Group By bk.Year Into Foo() Select Year
        Dim f As List(Of String) = From bk In books Select bk.Title
        Dim g = From bk In books Let bk = 1 Select bk
        Dim h As Expression(Of Func(Of Book, Boolean)) = Function(bk)
                                                                Return bk.Year > 1900
                                                            End Function
    End Sub
End Module
Program.vb(18,48): error BC36533: 'ByRef' parameter 'n' cannot be used in a query expression.
Program.vb(27,27): error BC36593: Expression of type 'Object' is not queryable. Make sure you are not missing an assembly reference and/or namespace import for the LINQ provider.
Program.vb(28,27): error BC36593: Expression of type 'Integer' is not queryable. Make sure you are not missing an assembly reference and/or namespace import for the LINQ provider.
Program.vb(29,69): error BC30512: Option Strict On disallows implicit conversions from 'String' to 'Double'.
Program.vb(30,64): error BC36622: You must reference at least one range variable on both sides of the 'Equals' operator. Range variable(s) 'bk' must appear on one side of the 'Equals' operator, and range variable(s) 'au' must appear on the other.
Program.vb(31,56): error BC36594: Definition of method 'Foo' is not accessible in this context.
Program.vb(31,62): error BC30518: Overload resolution failed because no accessible 'Select' can be called with these arguments:
Program.vb(32,36): error BC30512: Option Strict On disallows implicit conversions from 'IEnumerable(Of String)' to 'List(Of String)'.
Program.vb(33,38): error BC30978: Range variable 'bk' hides a variable in an enclosing block or a range variable previously defined in the query expression.
Program.vb(34,58): error BC36675: Statement lambdas cannot be converted to expression trees.

Syntax errors stop the compiler before semantic analysis, so they cannot be shown in the same program. The query below uses = instead of Equals, and the next one leaves out the Into list of Group By:

Program.vb(20,62): error BC36619: 'Equals' expected.
Program.vb(19,51): error BC36615: 'Into' expected.

A name that is not a range variable, and a Where condition that is not Boolean:

Program.vb(20,41): error BC36610: Name 'Title' is either not declared or not in the current scope.
Program.vb(21,34): error BC30518: Overload resolution failed because no accessible 'Where' can be called with these arguments:
Program.vb(21,34): error BC36594: Definition of method 'Where' is not accessible in this context.

The scope rule of Group By: after the clause the range variable y no longer exists, only the key c and the Into names do.

Option Strict On

Module Program
    Sub Main()
        Dim years As Integer() = {1815, 1965}
        Dim q = From y In years
                Group By c = y \ 100 Into Group
                Order By y
                Select c
    End Sub
End Module
Program.vb(8,26): error BC36610: Name 'y' is either not declared or not in the current scope.
Code Cause Fix

BC36593

the source is not queryable: Object (late bound), Integer or a type without the query operators

use a typed IEnumerable(Of T), or Cast(Of T)() / OfType(Of T)() on a non-generic collection

BC36533

a ByRef parameter used inside a query or lambda

copy to a local first

BC36594

a method such as Foo in Into Foo() or Where is not accessible in this context (with BC30518)

check the aggregate name, the condition type and the namespace imports

BC36610

a name that is neither a range variable nor in scope

use the range variable or add Let

BC36619

Equals expected after Join ... On

write Equals, not =

BC36622

the two sides of Equals do not each use range variables of one side only

split the expression so each side uses one source

BC36615

Into expected in Group By

add the Into list

BC30978

a range variable hides another variable or range variable

rename it

BC36675

a statement lambda converted to an expression tree

use a single expression

BC36754

anonymous types with and without Key mixed under Out generic variance

add Key consistently

BC30512

an implicit conversion refused by Option Strict On, for instance a query assigned to List(Of T), a Where condition that is not Boolean, or join keys of unrelated types

call ToList(), write a Boolean condition or convert the key

BC42327

warning: the loop control variable is used in a query

copy it to a local inside the loop

BC30209

Option Strict On requires As when Option Infer Off

keep Option Infer On

Pitfalls

The last program of the page collects three behaviours that the discussion above relies on: how many times an Aggregate with two functions runs its filter, when a Nothing source is detected, and that Sum is overflow-checked.

Option Strict On

Module Program
    Sub Main()
        ' 1. Aggregate with several functions: the filter runs once per function
        Dim filterCalls As Integer = 0
        Dim numbers As Integer() = {1, 2, 3, 4}
        Dim stats = Aggregate n In numbers
                    Where Seen(filterCalls, n) > 1
                    Into Count(), Sum(n)
        Console.WriteLine(stats.Count & " " & stats.Sum & " filter calls: " & filterCalls)

        ' 2. A Nothing source fails when the query is built, not when it is enumerated
        Dim missing As List(Of Integer) = Nothing
        Try
            Dim q = From n In missing Select n
            Console.WriteLine("query built")
        Catch ex As ArgumentNullException
            Console.WriteLine("Nothing source: " & ex.ParamName)
        End Try
        Console.WriteLine(String.Join(",", From n In If(missing, New List(Of Integer)) Select n).Length)

        ' 3. Sum of Integer is checked, whatever the project overflow setting is
        Dim big As Integer() = {Integer.MaxValue, 1}
        Try
            Console.WriteLine(big.Sum())
        Catch ex As OverflowException
            Console.WriteLine("Sum: " & ex.GetType().Name)
        End Try
        Console.WriteLine(big.Sum(Function(x) CLng(x)))
    End Sub

    Function Seen(ByRef calls As Integer, n As Integer) As Integer
        calls += 1
        Return n
    End Function
End Module
3 9 filter calls: 8
Nothing source: source
0
Sum: OverflowException
2147483648

Source: Enumerable.Sum

  • Writing a query does not run it, and a query that is enumerated twice runs twice; call ToList() to take a snapshot, or keep the query when you want it to see later changes.

  • ToList() on a very large source copies it; the streaming operators do not.

  • Side effects in Where and Select lambdas run at unpredictable times and possibly more than once.

  • Order By is stable, and Group By returns the groups in the order their keys first appear in the source.

  • Keys: use Key on every anonymous-type member that should take part in equality; do not rely on Option Compare Text for grouping, joining or distinct.

  • Single() throws on zero and on several matches; SingleOrDefault() throws on several; First() throws on zero. FirstOrDefault() returns the type’s default value, which is 0 for Integer, so test the result type before trusting a 0.

  • A ByRef parameter or the loop control variable of a For cannot be used safely in a query (BC36533, BC42327); copy it to an ordinary local first.

  • A Nothing source throws ArgumentNullException as soon as the query is built (the last program shows it), not when it is enumerated. Guard with If(source, Enumerable.Empty(Of T)()).

  • Prefer Any() to Count() > 0: it stops at the first element, and Count() on a plain sequence has to read all of it.

  • LINQ to Objects on Dictionary enumerates KeyValuePair(Of TKey, TValue) items; use Key and Value of the pair.

Where the books are outdated

The two books consulted for this section treat LINQ as a feature of the language and as a way to query databases. Three of their points are out of date for .NET 10:

  • LINQ to SQL (System.Data.Linq, DataContext) is not part of modern .NET. It is a .NET Framework technology that is no longer developed. The current tool for object-relational queries is Entity Framework Core, whose DbSet(Of T) is an IQueryable(Of T) and whose queries use the same query syntax shown here. EF6 (System.Data.Entity, automatic migrations, Enable-Migrations) is likewise replaced by EF Core with dotnet ef migrations add; the scaffolding tools emit C#, so a Visual Basic project writes its entity classes by hand or uses a community package. The data-access page has the details.

  • Query-versus-method syntax is presented as a choice of style; the compiler translation shown above makes clear that they are the same thing and may be mixed, and that several operators exist only as methods.

  • The VB-only clauses (Aggregate, Distinct, Skip, Take, Skip While, Take While, Group By ... Into) are usually not covered; they are shown on this page with their translations.

References