Articles › EF Core & SQL
SQL Joins and Essential Queries: A Practical SQL Server Guide
Master the SQL every developer needs: INNER, LEFT, RIGHT and FULL joins, GROUP BY and HAVING, subqueries, CTEs, window functions and paging — with clear SQL Server examples.
Even if you use an ORM every day, SQL is the language your database actually speaks. Knowing it well lets you read the queries EF Core generates, write reports efficiently and debug performance problems. This guide covers the queries you'll write again and again, using SQL Server syntax and a small e-commerce schema.
The sample schema
CREATE TABLE Customers (
Id INT PRIMARY KEY, Name NVARCHAR(100), City NVARCHAR(50)
);
CREATE TABLE Orders (
Id INT PRIMARY KEY, CustomerId INT NULL REFERENCES Customers(Id),
OrderDate DATE, Status NVARCHAR(20), Total DECIMAL(18,2)
);
CREATE TABLE OrderItems (
Id INT PRIMARY KEY, OrderId INT REFERENCES Orders(Id),
ProductName NVARCHAR(100), Quantity INT, UnitPrice DECIMAL(18,2)
);
The logical order of a SELECT
You write SELECT first, but SQL Server evaluates clauses in this order:
FROM/JOINWHEREGROUP BYHAVINGSELECTORDER BYOFFSET/FETCH
This explains common errors: you can't use a SELECT alias in WHERE (it doesn't exist yet), but you can in ORDER BY.
Joins
Joins combine rows from two tables based on a condition.
INNER JOIN — only matching rows
SELECT c.Name, o.Id AS OrderId, o.Total
FROM Customers c
INNER JOIN Orders o ON o.CustomerId = c.Id;
Customers without orders and orders without customers are excluded.
LEFT JOIN — everything from the left, matches from the right
SELECT c.Name, COUNT(o.Id) AS OrderCount
FROM Customers c
LEFT JOIN Orders o ON o.CustomerId = c.Id
GROUP BY c.Name;
Customers with no orders appear with OrderCount = 0 (note COUNT(o.Id), not COUNT(*), which would count 1).
A classic use — finding rows with no match:
-- Customers who have never ordered
SELECT c.*
FROM Customers c
LEFT JOIN Orders o ON o.CustomerId = c.Id
WHERE o.Id IS NULL;
Warning: Putting a condition on the right table in
WHEREturns a LEFT JOIN into an INNER JOIN. Filter the right table in theONclause instead:LEFT JOIN Orders o ON o.CustomerId = c.Id AND o.Status = 'Paid'
RIGHT and FULL OUTER JOIN
RIGHT JOINis a mirroredLEFT JOIN— usually rewritten as a LEFT JOIN for readability.FULL OUTER JOINkeeps unmatched rows from both sides — useful for reconciliation reports.
Multiple joins
SELECT c.Name, o.OrderDate, i.ProductName, i.Quantity * i.UnitPrice AS LineTotal
FROM Orders o
JOIN Customers c ON c.Id = o.CustomerId
JOIN OrderItems i ON i.OrderId = o.Id
WHERE o.OrderDate >= '2026-01-01';
| Join | Returns |
|---|---|
INNER |
rows matching in both tables |
LEFT |
all left rows + matches (NULLs otherwise) |
RIGHT |
all right rows + matches |
FULL OUTER |
all rows from both sides |
CROSS |
every combination (cartesian product) |
Aggregation: GROUP BY and HAVING
SELECT c.City,
COUNT(DISTINCT c.Id) AS Customers,
COUNT(o.Id) AS Orders,
SUM(o.Total) AS Revenue,
AVG(o.Total) AS AvgOrder
FROM Customers c
LEFT JOIN Orders o ON o.CustomerId = c.Id AND o.Status = 'Paid'
GROUP BY c.City
HAVING SUM(o.Total) > 10000000
ORDER BY Revenue DESC;
WHEREfilters rows before grouping.HAVINGfilters groups after aggregation.- Every non-aggregated column in
SELECTmust appear inGROUP BY.
Subqueries and EXISTS
-- Orders above the average order value
SELECT Id, Total
FROM Orders
WHERE Total > (SELECT AVG(Total) FROM Orders);
-- Customers who bought a specific product
SELECT c.Name
FROM Customers c
WHERE EXISTS (
SELECT 1
FROM Orders o
JOIN OrderItems i ON i.OrderId = o.Id
WHERE o.CustomerId = c.Id AND i.ProductName = N'Laptop'
);
EXISTS stops at the first match and handles NULLs safely. Prefer NOT EXISTS over NOT IN — NOT IN returns nothing if the subquery contains a single NULL.
Common Table Expressions (CTEs)
CTEs name an intermediate result, making complex queries readable:
WITH CustomerTotals AS (
SELECT CustomerId, SUM(Total) AS Spent
FROM Orders
WHERE Status = 'Paid'
GROUP BY CustomerId
)
SELECT c.Name, ct.Spent
FROM CustomerTotals ct
JOIN Customers c ON c.Id = ct.CustomerId
WHERE ct.Spent > 5000000
ORDER BY ct.Spent DESC;
Recursive CTEs walk hierarchies such as categories or org charts:
WITH CategoryTree AS (
SELECT Id, Name, ParentId, 0 AS Depth FROM Categories WHERE ParentId IS NULL
UNION ALL
SELECT c.Id, c.Name, c.ParentId, t.Depth + 1
FROM Categories c JOIN CategoryTree t ON c.ParentId = t.Id
)
SELECT * FROM CategoryTree ORDER BY Depth, Name;
Window functions
Window functions calculate across related rows without collapsing them like GROUP BY does.
SELECT
o.CustomerId,
o.Id,
o.Total,
ROW_NUMBER() OVER (PARTITION BY o.CustomerId ORDER BY o.OrderDate DESC) AS RecentRank,
SUM(o.Total) OVER (PARTITION BY o.CustomerId) AS CustomerTotal,
SUM(o.Total) OVER (ORDER BY o.OrderDate ROWS UNBOUNDED PRECEDING) AS RunningTotal,
LAG(o.Total) OVER (PARTITION BY o.CustomerId ORDER BY o.OrderDate) AS PreviousOrder
FROM Orders o;
The famous "latest order per customer" query:
WITH Ranked AS (
SELECT *, ROW_NUMBER() OVER (PARTITION BY CustomerId ORDER BY OrderDate DESC) AS rn
FROM Orders
)
SELECT * FROM Ranked WHERE rn = 1;
| Function | Purpose |
|---|---|
ROW_NUMBER() |
unique sequence per partition |
RANK() / DENSE_RANK() |
ranking with ties |
LAG() / LEAD() |
previous / next row's value |
SUM/AVG() OVER |
running totals, moving averages |
NTILE(n) |
split into n buckets (quartiles…) |
Paging
SELECT Id, OrderDate, Total
FROM Orders
ORDER BY OrderDate DESC, Id DESC
OFFSET 40 ROWS FETCH NEXT 20 ROWS ONLY; -- page 3, 20 per page
OFFSET requires ORDER BY, and the order should be deterministic (add a unique column like Id), otherwise rows can appear on two pages. For deep pages on huge tables, keyset paging (WHERE Id < @lastId) is much faster.
Handling NULL correctly
NULL = NULLis not true — useIS NULL/IS NOT NULL.- Aggregates ignore NULLs:
AVG(Discount)averages only non-null values. COALESCE(Discount, 0)replaces NULL with a default.
Data modification safely
BEGIN TRANSACTION;
UPDATE Orders
SET Status = 'Cancelled'
OUTPUT deleted.Status AS OldStatus, inserted.Id
WHERE Status = 'Pending' AND OrderDate < DATEADD(DAY, -30, GETDATE());
-- check the affected rows, then:
COMMIT; -- or ROLLBACK;
Always run the WHERE as a SELECT first before an UPDATE or DELETE.
Summary
Know the logical order of query evaluation, choose the right join (and keep LEFT JOIN filters in ON), aggregate with GROUP BY/HAVING, prefer EXISTS for existence checks, use CTEs for readability and window functions for rankings and running totals, and page with a deterministic ORDER BY. These patterns cover most real-world reporting and data access needs.
FAQ
Is JOIN faster than a subquery?
Often the optimizer produces the same plan. Write the clearest query first, then compare execution plans if performance matters.
What's the difference between UNION and UNION ALL?
UNION removes duplicates (extra work); UNION ALL keeps everything and is faster. Use UNION ALL unless you need de-duplication.
How do I see the SQL EF Core generates?
Call .ToQueryString() on an IQueryable, or enable logging with options.LogTo(Console.WriteLine).