Entity Framework Core: Joining Tables with LINQ

I often have difficulty remembering the exact syntax for joins using LINQ. I’m going to document some examples here for my own reference. I hope others also find value in this as well. This is a post that I will be expanding over time with more examples as I have time to document them.

Tables Schema for the Examples

For this example, I’ll be using my sample Customers and CustomerInvoices tables again.

Here’s a screenshot of the CustomerInvoices table structure:

Customer Invoices Table in SSMS

Customer Invoices Table

And here’s the SQL DDL for the table:


USE [appdb]
GO

SET ANSI_NULLS ON
GO

SET QUOTED_IDENTIFIER ON
GO

CREATE TABLE [dbo].[CustomerInvoices](
	[InvoiceId] [bigint] IDENTITY(1,1) NOT NULL,
	[CustomerId] [int] NOT NULL,
	[InvoiceAmount] [decimal](18, 2) NOT NULL,
	[InvoiceMessage] [varchar](1000) NULL,
 CONSTRAINT [PK_CustomerInvoices] PRIMARY KEY CLUSTERED 
(
	[InvoiceId] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
) ON [PRIMARY]
GO

ALTER TABLE [dbo].[CustomerInvoices]  WITH CHECK ADD  CONSTRAINT [FK_CustomerInvoices_Customers] FOREIGN KEY([CustomerId])
REFERENCES [dbo].[Customers] ([Id])
GO

ALTER TABLE [dbo].[CustomerInvoices] CHECK CONSTRAINT [FK_CustomerInvoices_Customers]
GO

Here’s a screenshot of the Customers table structure:

Customers Table in SSMS

Customers Table

And here’s the SQL DDL for the Customers table:


USE [appdb]
GO

SET ANSI_NULLS ON
GO

SET QUOTED_IDENTIFIER ON
GO

CREATE TABLE [dbo].[Customers](
	[Id] [int] IDENTITY(1,1) NOT NULL,
	[Name] [varchar](50) NULL,
	[Address] [varchar](250) NULL,
	[City] [varchar](50) NULL,
	[Region] [varchar](50) NULL,
	[PostalCode] [varchar](50) NULL,
	[Country] [varchar](3) NULL,
	[PhoneNumber] [varchar](20) NULL,
 CONSTRAINT [PK_Customers] PRIMARY KEY CLUSTERED 
(
	[Id] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
) ON [PRIMARY]
GO

Model Classes for the Examples

And here are the model classes for Customers and CustomerInvoice:


public class Customer
{
    public int Id { get; set; }
    public string? Name { get; set; }
    public string? Address { get; set; }
    public string? City { get; set; }
    public string? Region { get; set; }
    public string? PostalCode { get; set; }
    public string? Country { get; set; }
    public string? PhoneNumber { get; set; }
}

public class CustomerInvoice
{
    public long InvoiceId { get; set; }
    public int CustomerId { get; set; }
    public decimal InvoiceAmount { get; set; }
    public string? InvoiceMessage { get; set; }
    public Customer? Customer {get; set;}
}

For these examples, I’ve also created a map for the CustomerInvoice class:


using LinqExamples.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

public class CustomerInvoiceMap : IEntityTypeConfiguration<CustomerInvoice>
{
    public void Configure(EntityTypeBuilder<CustomerInvoice> builder)
    {
        builder.ToTable("CustomerInvoices");
        builder.HasKey(c => c.InvoiceId);
        builder.Property(p => p.InvoiceAmount)
               .HasPrecision(18, 2);
    }
}

The map tells Entity Framework Core what the primary key of the table is, as “InvoiceId” does not follow the standard EF Core convention of using just Id or <TypeName>Id as the primary key name.

The map also lets EF Core know the precision of the InvoiceAmount column.

Inner Join Example

Here is the console Program.cs file containing the join examples:


using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.EntityFrameworkCore;
using LinqExamples;


var builder = Host.CreateApplicationBuilder(args);

builder.Configuration.AddJsonFile("appsettings.json");

var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
if (connectionString == null)
{
    Console.WriteLine("Connection string cannot be null.");
    return;
}

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(connectionString));

var app = builder.Build();

using var scope = app.Services.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();

var query = context.CustomerInvoices.Join(context.Customers,
    invoice => invoice.CustomerId, customer => customer.Id,
    (invoice, customer) => new
    {
        customer.Id,
        customer.Name,
        customer.Address,
        invoice.InvoiceId,
        invoice.InvoiceAmount,
        invoice.InvoiceMessage
    });

var customerInvoices = query.Where(x => x.Id == 1).ToList();

Console.WriteLine(customerInvoices[0]);
Console.WriteLine();
Console.ReadKey();

var query2 = context.CustomerInvoices.Join(context.Customers,
    invoice => invoice.CustomerId, customer => customer.Id,
    (invoice, customer) => new
    {
        CustomerId = customer.Id,
        CustomerName = customer.Name,
        CustomerAddress = customer.Address,
        invoice.InvoiceId,
        invoice.InvoiceAmount,
        invoice.InvoiceMessage
    });

var customerInvoices2 = query2.Where(x => x.CustomerId == 2).ToList();

Console.WriteLine(customerInvoices2[0]);
Console.ReadKey();

Inner Join

This code snippet from the above Program.cs file shows the first join example:

var query = context.CustomerInvoices.Join(context.Customers,
    invoice => invoice.CustomerId, customer => customer.Id,
    (invoice, customer) => new
    {
        customer.Id,
        customer.Name,
        customer.Address,
        invoice.InvoiceId,
        invoice.InvoiceAmount,
        invoice.InvoiceMessage
    });

var customerInvoices = query.Where(x => x.Id == 1).ToList();

The above code is doing a simple inner join, joining all matching records in CustomerInvoices and Customers where the records match on invoice.CustomerId and customer.Id. Then it uses the .Where to query the database using the join.

When we run the Program.cs code up to the first WriteLine, we see this output:

Console Output of First Inner Join Example

Console Output for Example 1

Inner Join - Renaming Properties

This code snippet from the above Program.cs file shows the second join example:

var query2 = context.CustomerInvoices.Join(context.Customers,
    invoice => invoice.CustomerId, customer => customer.Id,
    (invoice, customer) => new
    {
        CustomerId = customer.Id,
        CustomerName = customer.Name,
        CustomerAddress = customer.Address,
        invoice.InvoiceId,
        invoice.InvoiceAmount,
        invoice.InvoiceMessage
    });

var customerInvoices2 = query2.Where(x => x.CustomerId == 2).ToList();

This is still doing a simple inner join using the same type of join logic as before. The difference is that this example shows how we can rename the fields in the object returned by EF Core.

When the run the Program.cs code up to the second WriteLine, we see this output:

Console Output of Second Inner Join Example

Console Output for Example 2

The difference in the second example is that the columns are renamed. “Id” becomes “CustomerId”, “Name” becomes “CustomerName”, etc.

A Note on Navigations

NOTE: The tables used in these examples have a proper foreign key relationship, so using navigations would work and be preferred here. However, this post is about join syntax, rather than about the idiomatic way of querying this type of relationship. Here’s an example of what using navigations to accomplish the same join would look like:

var results = context.CustomerInvoices
    .Where(ci => ci.CustomerId == 2)
    .Select(ci => new {
        CustomerId = ci.Customer.Id,
        CustomerName = ci.Customer.Name,
        CustomerAddress = ci.Customer.Address,
        ci.InvoiceId,
        ci.InvoiceAmount,
        ci.InvoiceMessage
    })
    .ToList();

Final Thoughts

This is just a reference post I can refer back to when I need a quick refresher on the syntax for joins using LINQ. As I mentioned at the beginning of the post, I will gradually expand this with more examples. I’ll add left outer joins next. Right/full joins may require workarounds or raw SQL, but I plan to address those as well.


The postings on this site are my own and do not necessarily reflect the views of my employer.

The content on this blog is for informational and educational purposes only and represents my personal opinions and experience. While I strive to provide accurate and up-to-date information, I make no guarantees regarding the completeness, reliability, or accuracy of the information provided.

By using this website, you acknowledge that any actions you take based on the information provided here are at your own risk. I am not liable for any losses, damages, or issues arising from the use or misuse of the content on this blog.

Please consult a qualified professional or conduct your own research before implementing any solutions or advice mentioned here.