Updated on
An outer join in LINQ keeps the rows that have no match on the other side. An inner join throws them away, so a song whose author is missing from the author list disappears from the result entirely.
On .NET 10, LeftJoin() and RightJoin() do this in one call, next to the Join() method that has always done inner joins. On earlier versions there is no such operator, and the same result comes from GroupJoin() followed by DefaultIfEmpty(), which is also the only form LINQ query syntax can express.
Both routes are below, along with the full outer join and the three excluding joins, which are built from the left and right joins.
It’s hard to find an application that doesn’t rely on data. We often use just a single data source. But sometimes we need data from more than one source, be it a database, a file, or a C# collection, to mention just a few. This is where joins come in handy. There are a couple of types of joins and we will take a closer look at them.
Use of Outer Join in LINQ
In the most common scenario, we need data from one source that is somehow related to data in another source. We can use inner joins when we need to combine matching data coming from different sources.
But when do we use outer joins?
A typical scenario is when we have to handle missing data. We could, for example, set a default value for any missing piece of data in either source.Â
Another use case is data analysis. We can use outer joins to compare data in two data sources or to find any discrepancies between them.
Finally, we use outer joins to merge data. This is the case when we need to preserve all the records from one or both of the sources while combining the data.
To demonstrate how outer joins work and how to use them in LINQ, let’s start by creating some sample data sources.
Data Setup for Outer Joins
Let’s create a Song class and an Author class. To keep things simple, we’ll only add a few properties to each:
public class Song
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public int AuthorId { get; set; }
}
public class Author
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
}
Each song has an author, hence the AuthorId as a foreign key in the class Song. Next, let’s create a list of songs and a list of authors:
public List<Song> Songs =
[
new Song() { Id = 1, Title = "Alabama Sundown", AuthorId = 3},
new Song() { Id = 2, Title = "New Beginning", AuthorId = 1},
new Song() { Id = 3, Title = "To the Stars", AuthorId = 5},
new Song() { Id = 4, Title = "First Time Ever", AuthorId = 2},
new Song() { Id = 5, Title = "Nobody Listens", AuthorId = 4},
new Song() { Id = 6, Title = "New Love", AuthorId = 1},
];
public List<Author> Authors =
[
new Author() { Id = 1, Name = "Sue Sherrington"},
new Author() { Id = 2, Name = "Luke Palmer" },
new Author() { Id = 3, Name = "Gwen Felice" },
new Author() { Id = 6, Name = "Brian Moore" },
new Author() { Id = 7, Name = "Roy Cobbler" }
];
Our two lists represent the two data sources that we’ll be using in our examples. We can see that there are both matching and non-matching data. Some songs have the AuthorId property set to a value that doesn’t exist in the author list. Also, we have authors with no songs in the first list.Â
We can use anonymous types in our LINQ query. We’re going to define a type named SongWithAuthor:
public class SongWithAuthor
{
public string Title { get; set; } = string.Empty;
public string AuthorName { get; set; } = string.Empty;
}
This way, our query will return us a list of SongWithAuthor objects.
Now that our data is prepared, we can utilize several types of outer joins on them.Â
What Is the Difference Between a Left Join and a Right Join?
A left join returns every element of the first sequence, matched or not. A right join returns every element of the second sequence on the same terms. Both also return the matching pairs, which is everything an inner join gives us.
An element with no match on the other side still produces a row. The missing side arrives as null for a reference type, or as the type’s default value, and the projection decides what goes there: a placeholder string or a zero.
On our data, a left join from songs keeps the two songs whose AuthorId matches no author, and a right join keeps the two authors who wrote none of these songs.
Until .NET 10 there was no right-join operator, and the way to get one was to run a left join with the two sequences swapped. .NET 10 gives each direction its own method.

The amber rows are the whole difference between the two joins. The songs with no matching author survive only the left join, and the authors with no songs survive only the right join.
How Do We Use the LeftJoin and RightJoin Methods in .NET 10?
.NET 10 adds LeftJoin() and RightJoin() to System.Linq, beside the Join() method that has always produced inner joins. Each takes the same arguments as Join(): the second sequence, a key selector for each side, and a result selector.
The signatures differ from Join() in one place. On LeftJoin(), the result selector is a Func<TOuter, TInner?, TResult>, so the inner element is the nullable one. On RightJoin() the outer element is nullable instead. The compiler tells us which side can be absent, so a missing null check shows up as a warning.
Both Enumerable and Queryable received the operators, so the same call works over a list held in memory and over an IQueryable that a provider translates.
An overload of each accepts an IEqualityComparer<TKey>, for the cases where default key comparison is not the comparison we want, such as matching string keys without regard to case.
Both joins become a single call, and the projection is the only place the missing side needs handling:
public static List<SongWithAuthor> PerformLeftJoinWithLeftJoinMethod(
List<Song> songs, List<Author> authors)
{
var results = songs.LeftJoin(
authors,
song => song.AuthorId,
author => author.Id,
(song, author) => new SongWithAuthor
{
Title = song.Title,
AuthorName = author?.Name ?? "unknown"
});
return results.ToList();
}
public static List<SongWithAuthor> PerformRightJoinWithRightJoinMethod(
List<Song> songs, List<Author> authors)
{
var results = songs.RightJoin(
authors,
song => song.AuthorId,
author => author.Id,
(song, author) => new SongWithAuthor
{
Title = song?.Title ?? "-",
AuthorName = author.Name
});
return results.ToList();
}
The right join passes songs first, in the same order as the left join. The choice of operator alone decides which side keeps its unmatched elements.
Microsoft Learn’s reference for RightJoin() puts it in terms of argument position: “elements of the second sequence are returned regardless of whether matching elements are found in the other sequence”.
How Do We Use GroupJoin and DefaultIfEmpty for an Outer Join?
GroupJoin() correlates two sequences and hands back, for each element of the first, the group of elements that matched it. That group is empty when nothing matched, and flattening an empty group drops the element, which is why GroupJoin() followed by SelectMany() alone still behaves like an inner join.
DefaultIfEmpty() is what turns it into a left join. Called on each group, it substitutes a single default element for an empty group, so the unmatched element of the first sequence survives into the result with null on the other side.
Query syntax spells this as join … into followed by a second from over DefaultIfEmpty(). Method syntax spells it as GroupJoin() followed by SelectMany(), with DefaultIfEmpty() called inside the SelectMany().
This is the form to use on .NET 9 and earlier, and it remains the only outer join LINQ query syntax can express on any version.
Query Syntax
To perform an outer join, we first have to use a group join and then call the DefaultIfEmpty() method on it. Flattening the groups without DefaultIfEmpty() gives an inner join, because an empty group produces no row. The DefaultIfEmpty() method ensures all elements from the first list are included, even if they’re unmatched:
public static List<SongWithAuthor> PerformLeftJoinWithQuerySyntax(
List<Song> songs, List<Author> authors)
{
var results =
from s in songs
join a in authors
on s.AuthorId equals a.Id
into songsWithAuthors
from sa in songsWithAuthors.DefaultIfEmpty()
select new SongWithAuthor { Title = s.Title, AuthorName = sa == null ? "unknown" : sa.Name };
return results.ToList();
}
Here we have a function that performs a left join using query syntax and returns the results:
Left outer join - query syntax Title: Alabama Sundown, Author: Gwen Felice Title: New Beginning, Author: Sue Sherrington Title: To the Stars, Author: unknown Title: First Time Ever, Author: Luke Palmer Title: Nobody Listens, Author: unknown Title: New Love, Author: Sue Sherrington
As expected, we have all the elements from the first data source songs and only the matching data from the second source. If any data is missing in the second source, the default value of “unknown” is returned.
To perform the right outer join, we have to swap the data sources:
public static List<SongWithAuthor> PerformRightJoinWithQuerySyntax(
List<Song> songs, List<Author> authors)
{
var results =
from a in authors
join s in songs
on a.Id equals s.AuthorId
into songsWithAuthors
from sa in songsWithAuthors.DefaultIfEmpty()
select new SongWithAuthor { Title = sa == null ? "-" : sa.Title, AuthorName = a.Name };
return results.ToList();
}
This time, we get all the data from the second source authors and only the matching data from the first source:
Right outer join - query syntax Title: New Beginning, Author: Sue Sherrington Title: New Love, Author: Sue Sherrington Title: First Time Ever, Author: Luke Palmer Title: Alabama Sundown, Author: Gwen Felice Title: -, Author: Brian Moore Title: -, Author: Roy Cobbler
Any missing data in the first source is replaced by a hyphen.Â
Method Syntax
With the method syntax, we have to use the GroupJoin() method. From there, we have to use the SelectMany() and DefaultIfEmpty() methods. Let’s start with the left outer join:
public static List<SongWithAuthor> PerformLeftJoinWithMethodSyntax(
List<Song> songs, List<Author> authors)
{
var results = songs
.GroupJoin(
authors,
song => song.AuthorId,
author => author.Id,
(song, author) => new { song, author }
)
.SelectMany(
left => left.author.DefaultIfEmpty(),
(song, author) => new SongWithAuthor
{
Title = song.song.Title,
AuthorName = author == null ? "unknown" : author.Name
}
);
return results.ToList();
}
We use the GroupJoin() method on the first data source, which in our case is songs. The second data source (authors) is passed as the first argument to the method. Next, we define the outer key selector (song.AuthorId) and inner key selector (author.Id). These are the properties used for matching. Then we project the result using an anonymous type.
In the SelectMany() method, we call the DefaultIfEmpty() method to perform the left join. The final result consists of objects of the SongWithAuthor type. Alternatively, we could use an anonymous type. If we were using query syntax, we would end up with the same result.
Our article on SelectMany() and how it flattens a nested sequence explains the step that turns each group into rows.
For the sake of completeness, let’s re-create the right outer join using method syntax:
public static List<SongWithAuthor> PerformRightJoinWithMethodSyntax(
List<Song> songs, List<Author> authors)
{
var results = authors
.GroupJoin(
songs,
author => author.Id,
song => song.AuthorId,
(author, song) => new { author, song }
)
.SelectMany(
right => right.song.DefaultIfEmpty(),
(author, song) => new SongWithAuthor
{
Title = song == null ? "-" : song.Title,
AuthorName = author.author.Name,
}
);
return results.ToList();
}
Just like the left outer join, we use the GroupJoin() method in conjunction with the SelectMany() method. The only difference is that we swap the data sources.
Explore Other Outer Joins
The left and right outer joins we just discussed are relatively common. But they are not the only ones. The other types include the full outer join, left excluding join, right excluding join, and full outer excluding join.
Full Outer Join
The full outer join returns all matching and non-matching data from both sources. Again, any missing data is filled with the default value. To perform a full outer join, we first perform a left join, then a right join, and finally we merge the results of the two.
For the Union() method to work correctly, we have to define a class that implements the IEqualityComparer interface:
internal class SongWithAuthorComparer : IEqualityComparer<SongWithAuthor>
{
public bool Equals(SongWithAuthor? x, SongWithAuthor? y)
{
return x?.AuthorName == y?.AuthorName && x?.Title == y?.Title;
}
public int GetHashCode([DisallowNull] SongWithAuthor obj)
{
return HashCode.Combine(obj.Title, obj.AuthorName);
}
}
Here, we specify what it means for two SongWithAuthor objects to be equal. We consider them equal if their AuthorName and Title properties are identical.
Now, we can use our SongWithAuthorComparer along with the Union() method:
public static List<SongWithAuthor> PerformFullOuterJoin(
List<Song> songs, List<Author> authors)
{
var resultsLeft = PerformLeftJoinWithQuerySyntax(songs, authors);
var resultsRight = PerformRightJoinWithQuerySyntax(songs, authors);
var results = resultsLeft.Union(resultsRight, new SongWithAuthorComparer());
return results.ToList();
}
Here we make use of the PerformLeftJoinWithQuerySyntax() and PerformRightJoinWithQuerySyntax() methods, which we previously defined. Also, we could substitute these methods for the ones that use the LINQ method syntax.
Then, we call the Union() method on our resultsLeft collection, passing in our resultsRight collection and an instance of our SongWithAuthorComparer class, so the Union() method understands what we deem to be equal.
Now the union will return the results from the left and right outer joins, excluding duplicates:
Full outer join Title: Alabama Sundown, Author: Gwen Felice Title: New Beginning, Author: Sue Sherrington Title: To the Stars, Author: unknown Title: First Time Ever, Author: Luke Palmer Title: Nobody Listens, Author: unknown Title: New Love, Author: Sue Sherrington Title: -, Author: Brian Moore Title: -, Author: Roy Cobbler
The Excluding Joins
An excluding join returns non-matching data. We can implement it as the difference between two collections. In LINQ we use the Except() method to this end. For a closer look, see our article on the Except() method and what it needs from a comparer.
In each of the three excluding joins, we’ll be calculating the difference between an outer join and the inner join. To do that, we need a method that returns the inner join:
public static List<SongWithAuthor> PerformInnerJoin(
List<Song> songs, List<Author> authors)
{
var results =
from s in songs
join a in authors
on s.AuthorId equals a.Id
select new SongWithAuthor { Title = s.Title, AuthorName = a.Name };
return results.ToList();
}
Left Excluding Join
The left excluding join returns the non-matching data from the first source. We implement it as the difference between the left join and the inner join:
public static List<SongWithAuthor> PerformLeftExcludingJoin(
List<Song> songs, List<Author> authors)
{
var resultsLeft = PerformLeftJoinWithQuerySyntax(songs, authors);
var resultsInner = PerformInnerJoin(songs, authors);
var results = resultsLeft.Except(resultsInner, new SongWithAuthorComparer());
return results.ToList();
}
Here we’re again making use of methods that we defined before. As our result, we get only the data that is in the first source, but not in the other:
Left excluding join Title: To the Stars, Author: unknown Title: Nobody Listens, Author: unknown
Right Excluding Join
The right excluding join returns the non-matching data from the second source. This time, what we need is the difference between the right join and the inner join:
public static List<SongWithAuthor> PerformRightExcludingJoin(
List<Song> songs, List<Author> authors)
{
var resultsRight = PerformRightJoinWithQuerySyntax(songs, authors);
var resultsInner = PerformInnerJoin(songs, authors);
var results = resultsRight.Except(resultsInner, new SongWithAuthorComparer());
return results.ToList();
}
This method returns the elements that are in the second data source, but not in the first one:
Right excluding join Title: -, Author: Brian Moore Title: -, Author: Roy Cobbler
Full Outer Excluding Join With LINQ
The full outer excluding join returns the non-matching data from both sources. We can visualize it as the difference between the full outer join and the inner join:
public static List<SongWithAuthor> PerformFullOuterExcludingJoin(
List<Song> songs, List<Author> authors)
{
var resultsFull = PerformFullOuterJoin(songs, authors);
var resultsInner = PerformInnerJoin(songs, authors);
var results = resultsFull.Except(resultsInner, new SongWithAuthorComparer());
return results.ToList();
}
This join returns all the elements that are only in one data source, but not in the other:
Full outer excluding join Title: To the Stars, Author: unknown Title: Nobody Listens, Author: unknown Title: -, Author: Brian Moore Title: -, Author: Roy Cobbler
Which LINQ Call Produces Which Join?
Seven join shapes come out of three building blocks: a join, a default for the missing side, and a set operation over two results.
Join() is the inner join and returns matched pairs only. LeftJoin() and RightJoin() add the unmatched elements of one chosen side. Before .NET 10, the left join came from GroupJoin() with DefaultIfEmpty(), and the right join came from running that same construction with the sequences swapped.
The other four shapes are arithmetic over those results. A full outer join is the left join unioned with the right join. Each excluding join is one of the joins with the inner join subtracted through Except().
All four of those set operations need an IEqualityComparer<T> for the projected type. SongWithAuthor is a class, so without one the comparison falls back to reference equality, and every projected row then looks distinct from every other.
The table lists all seven, with the .NET 10 call beside the construction that works on any version.
| Join | .NET 10 and later | Any version | Rows returned |
|---|---|---|---|
| Inner | Join() | Join() | matched pairs only |
| Left outer | LeftJoin() | GroupJoin() then SelectMany() with DefaultIfEmpty() | all matched pairs, plus every unmatched element of the first sequence |
| Right outer | RightJoin() | the left-join form with the two sequences swapped | all matched pairs, plus every unmatched element of the second sequence |
| Full outer | LeftJoin() unioned with RightJoin() | left join unioned with right join | every element of both sequences |
| Left excluding | left join, then Except() the inner join | same | only the unmatched elements of the first sequence |
| Right excluding | right join, then Except() the inner join | same | only the unmatched elements of the second sequence |
| Full outer excluding | full outer join, then Except() the inner join | same | only the unmatched elements of both sequences |
The inner join in the first row is the only one that drops unmatched elements on both sides. Our article on how an inner join works in LINQ covers it on its own.
Conclusion
Which call builds a left or right outer join depends on the .NET version. On .NET 10, LeftJoin() and RightJoin() each do it in one call. On .NET 9 and earlier it takes GroupJoin() followed by DefaultIfEmpty(). That form gives the same rows in method syntax and query syntax, and it is the only one query syntax can express on any version. The full outer join is the left and right results merged with Union(), each excluding join is an outer join with the inner join subtracted through Except(), and all four of those set operations need an IEqualityComparer<T> for the projected type.
Every join in this article is written out in code before the program runs. When a query’s shape is only known once the program is running, our article on building LINQ queries at runtime with Dynamic LINQ shows how to build one.
Tested with .NET 10.0.10.
