SQL Joins - Combining Data from Multiple Tables

Database August 30, 2025 32 views

SQL Joins - Combining Data from Multiple Tables

Understanding Relationships

Before diving into joins, understand how tables relate to each other.

Sample Schema

-- Categories table
CREATE TABLE Categories (
    CategoryID INT PRIMARY KEY,
    CategoryName NVARCHAR(50),
    Description NVARCHAR(200)
);

-- Products table
CREATE TABLE Products (
    ProductID INT PRIMARY KEY,
    ProductName NVARCHAR(100),
    CategoryID INT FOREIGN KEY REFERENCES Categories(CategoryID),
    Price DECIMAL(10,2),
    Stock INT
);

-- Orders table
CREATE TABLE Orders (
    OrderID INT PRIMARY KEY,
    OrderDate DATETIME,
    CustomerID INT,
    TotalAmount DECIMAL(10,2)
);

-- OrderItems table
CREATE TABLE OrderItems (
    OrderItemID INT PRIMARY KEY,
    OrderID INT FOREIGN KEY REFERENCES Orders(OrderID),
    ProductID INT FOREIGN KEY REFERENCES Products(ProductID),
    Quantity INT,
    UnitPrice DECIMAL(10,2)
);

INNER JOIN

Returns only rows where there's a match in both tables.

Basic INNER JOIN

-- Products with their category names
SELECT
    p.ProductID,
    p.ProductName,
    c.CategoryName,
    p.Price
FROM Products p
INNER JOIN Categories c ON p.CategoryID = c.CategoryID;

Visual Representation

Products Table          Categories Table
+-----------+-----+     +-----+----------+
| ProductID | Cat |     | Cat | Name     |
+-----------+-----+     +-----+----------+
| 1         | 1   | --> | 1   | Electronics
| 2         | 2   | --> | 2   | Books
| 3         | 1   | --> | 1   | Electronics
+-----------+-----+     +-----+----------+

Result: 3 rows (all match)

Multiple INNER JOINs

-- Order details with product and category info
SELECT
    o.OrderID,
    o.OrderDate,
    p.ProductName,
    c.CategoryName,
    oi.Quantity,
    oi.UnitPrice,
    oi.Quantity * oi.UnitPrice AS LineTotal
FROM Orders o
INNER JOIN OrderItems oi ON o.OrderID = oi.OrderID
INNER JOIN Products p ON oi.ProductID = p.ProductID
INNER JOIN Categories c ON p.CategoryID = c.CategoryID;

INNER JOIN with WHERE

-- Electronics products ordered in 2025
SELECT
    p.ProductName,
    SUM(oi.Quantity) AS TotalQuantitySold
FROM Products p
INNER JOIN Categories c ON p.CategoryID = c.CategoryID
INNER JOIN OrderItems oi ON p.ProductID = oi.ProductID
INNER JOIN Orders o ON oi.OrderID = o.OrderID
WHERE c.CategoryName = 'Electronics'
  AND YEAR(o.OrderDate) = 2025
GROUP BY p.ProductName
ORDER BY TotalQuantitySold DESC;

LEFT JOIN (LEFT OUTER JOIN)

Returns all rows from the left table and matching rows from the right table. NULL for no match.

Basic LEFT JOIN

-- All categories, even those without products
SELECT
    c.CategoryID,
    c.CategoryName,
    COUNT(p.ProductID) AS ProductCount
FROM Categories c
LEFT JOIN Products p ON c.CategoryID = p.CategoryID
GROUP BY c.CategoryID, c.CategoryName;

Visual Representation

Categories (LEFT)       Products (RIGHT)
+-----+----------+      +-----------+-----+
| Cat | Name     |      | ProductID | Cat |
+-----+----------+      +-----------+-----+
| 1   | Electronics <-- | 1         | 1   |
| 2   | Books    <--    | 2         | 2   |
| 3   | Toys     |      +-----------+-----+
| 4   | Games    |
+-----+----------+

Result: 4 rows (all categories, even without products)

Finding Orphaned Records

-- Categories with no products
SELECT
    c.CategoryID,
    c.CategoryName
FROM Categories c
LEFT JOIN Products p ON c.CategoryID = p.CategoryID
WHERE p.ProductID IS NULL;

Multiple LEFT JOINs

-- All products with orders (if any) and categories
SELECT
    p.ProductName,
    c.CategoryName,
    ISNULL(SUM(oi.Quantity), 0) AS TotalSold
FROM Products p
LEFT JOIN Categories c ON p.CategoryID = c.CategoryID
LEFT JOIN OrderItems oi ON p.ProductID = oi.ProductID
GROUP BY p.ProductName, c.CategoryName;

RIGHT JOIN (RIGHT OUTER JOIN)

Returns all rows from the right table and matching rows from the left table. (Less commonly used than LEFT JOIN)

-- Same as LEFT JOIN but reversed
SELECT
    c.CategoryID,
    c.CategoryName,
    p.ProductName
FROM Products p
RIGHT JOIN Categories c ON p.CategoryID = c.CategoryID;

-- This is equivalent to:
SELECT
    c.CategoryID,
    c.CategoryName,
    p.ProductName
FROM Categories c
LEFT JOIN Products p ON c.CategoryID = p.CategoryID;

Tip: Most developers prefer LEFT JOIN and simply swap table order rather than using RIGHT JOIN.

FULL OUTER JOIN

Returns all rows from both tables, with NULL where there's no match.

-- All categories and all products (even orphaned ones)
SELECT
    c.CategoryName,
    p.ProductName
FROM Categories c
FULL OUTER JOIN Products p ON c.CategoryID = p.CategoryID;

Visual Representation

Categories              Products
+-----+----------+      +-----------+----------+-----+
| Cat | Name     |      | ProductID | Name     | Cat |
+-----+----------+      +-----------+----------+-----+
| 1   | Electronics <-> | 1         | Phone    | 1   |
| 2   | Books    <->    | 2         | Novel    | 2   |
| 3   | Toys     |      | 3         | Mystery  | NULL|
+-----+----------+      +-----------+----------+-----+

Result: 4 rows (all categories AND all products)

CROSS JOIN

Cartesian product - every row from table A with every row from table B.

-- All possible product-category combinations
SELECT
    c.CategoryName,
    p.ProductName
FROM Categories c
CROSS JOIN Products p;

-- If Categories has 4 rows and Products has 10 rows
-- Result: 40 rows (4 × 10)

Practical Use Case

-- Generate all dates for a month with all products
WITH DateRange AS (
    SELECT CAST('2025-01-01' AS DATE) AS SaleDate
    UNION ALL
    SELECT DATEADD(DAY, 1, SaleDate)
    FROM DateRange
    WHERE SaleDate < '2025-01-31'
)
SELECT
    dr.SaleDate,
    p.ProductName,
    ISNULL(s.Quantity, 0) AS QuantitySold
FROM DateRange dr
CROSS JOIN Products p
LEFT JOIN Sales s ON dr.SaleDate = s.SaleDate
                  AND p.ProductID = s.ProductID
OPTION (MAXRECURSION 31);

SELF JOIN

Join a table to itself - useful for hierarchical data.

-- Employee table with manager relationship
CREATE TABLE Employees (
    EmployeeID INT PRIMARY KEY,
    EmployeeName NVARCHAR(50),
    ManagerID INT NULL
);

-- Find employees with their managers
SELECT
    e.EmployeeName AS Employee,
    m.EmployeeName AS Manager
FROM Employees e
LEFT JOIN Employees m ON e.ManagerID = m.EmployeeID;

-- Find all pairs of employees in same department
SELECT
    e1.EmployeeName AS Employee1,
    e2.EmployeeName AS Employee2
FROM Employees e1
INNER JOIN Employees e2 ON e1.DepartmentID = e2.DepartmentID
WHERE e1.EmployeeID < e2.EmployeeID; -- Avoid duplicates

JOIN Conditions Beyond Equality

Non-Equi Joins

-- Price ranges
CREATE TABLE PriceRanges (
    RangeID INT PRIMARY KEY,
    MinPrice DECIMAL(10,2),
    MaxPrice DECIMAL(10,2),
    RangeName NVARCHAR(50)
);

-- Products with their price range
SELECT
    p.ProductName,
    p.Price,
    pr.RangeName
FROM Products p
INNER JOIN PriceRanges pr
    ON p.Price >= pr.MinPrice
    AND p.Price < pr.MaxPrice;

Multiple Conditions

-- Join on multiple columns
SELECT
    o.OrderID,
    p.ProductName
FROM Orders o
INNER JOIN OrderItems oi
    ON o.OrderID = oi.OrderID
INNER JOIN Products p
    ON oi.ProductID = p.ProductID
    AND p.IsActive = 1  -- Additional condition
    AND p.Stock > 0;

Subquery JOINs

-- Join to subquery result
SELECT
    p.ProductName,
    p.Price,
    avg_cat.AvgPrice,
    p.Price - avg_cat.AvgPrice AS DiffFromAvg
FROM Products p
INNER JOIN (
    SELECT
        CategoryID,
        AVG(Price) AS AvgPrice
    FROM Products
    GROUP BY CategoryID
) avg_cat ON p.CategoryID = avg_cat.CategoryID;

-- CTE approach (cleaner)
WITH CategoryAvgPrices AS (
    SELECT
        CategoryID,
        AVG(Price) AS AvgPrice
    FROM Products
    GROUP BY CategoryID
)
SELECT
    p.ProductName,
    p.Price,
    cap.AvgPrice,
    p.Price - cap.AvgPrice AS DiffFromAvg
FROM Products p
INNER JOIN CategoryAvgPrices cap ON p.CategoryID = cap.CategoryID;

Complex Real-World Examples

Example 1: Sales Report

-- Monthly sales by category with previous month comparison
WITH MonthlySales AS (
    SELECT
        DATEPART(YEAR, o.OrderDate) AS Year,
        DATEPART(MONTH, o.OrderDate) AS Month,
        c.CategoryName,
        SUM(oi.Quantity * oi.UnitPrice) AS TotalSales
    FROM Orders o
    INNER JOIN OrderItems oi ON o.OrderID = oi.OrderID
    INNER JOIN Products p ON oi.ProductID = p.ProductID
    INNER JOIN Categories c ON p.CategoryID = c.CategoryID
    GROUP BY
        DATEPART(YEAR, o.OrderDate),
        DATEPART(MONTH, o.OrderDate),
        c.CategoryName
)
SELECT
    curr.Year,
    curr.Month,
    curr.CategoryName,
    curr.TotalSales AS CurrentMonth,
    prev.TotalSales AS PreviousMonth,
    curr.TotalSales - ISNULL(prev.TotalSales, 0) AS Change
FROM MonthlySales curr
LEFT JOIN MonthlySales prev
    ON curr.CategoryName = prev.CategoryName
    AND curr.Year = prev.Year
    AND curr.Month = prev.Month + 1
ORDER BY curr.Year, curr.Month, curr.CategoryName;

Example 2: Customer Analysis

-- Customer order summary with first/last order dates
SELECT
    c.CustomerID,
    c.CustomerName,
    COUNT(DISTINCT o.OrderID) AS TotalOrders,
    SUM(o.TotalAmount) AS TotalSpent,
    AVG(o.TotalAmount) AS AvgOrderValue,
    MIN(o.OrderDate) AS FirstOrder,
    MAX(o.OrderDate) AS LastOrder,
    DATEDIFF(DAY, MAX(o.OrderDate), GETDATE()) AS DaysSinceLastOrder,
    COUNT(DISTINCT p.CategoryID) AS UniqueCategoriesPurchased
FROM Customers c
INNER JOIN Orders o ON c.CustomerID = o.CustomerID
INNER JOIN OrderItems oi ON o.OrderID = oi.OrderID
INNER JOIN Products p ON oi.ProductID = p.ProductID
GROUP BY c.CustomerID, c.CustomerName
HAVING COUNT(DISTINCT o.OrderID) > 1
ORDER BY TotalSpent DESC;

Example 3: Inventory Report

-- Products with low stock and recent sales velocity
WITH SalesLast30Days AS (
    SELECT
        p.ProductID,
        ISNULL(SUM(oi.Quantity), 0) AS QuantitySold
    FROM Products p
    LEFT JOIN OrderItems oi ON p.ProductID = oi.ProductID
    LEFT JOIN Orders o ON oi.OrderID = o.OrderID
    WHERE o.OrderDate >= DATEADD(DAY, -30, GETDATE())
    GROUP BY p.ProductID
)
SELECT
    p.ProductName,
    c.CategoryName,
    p.Stock AS CurrentStock,
    s.QuantitySold AS SoldLast30Days,
    CASE
        WHEN s.QuantitySold = 0 THEN 999
        ELSE p.Stock / (s.QuantitySold / 30.0)
    END AS DaysOfStockRemaining
FROM Products p
INNER JOIN Categories c ON p.CategoryID = c.CategoryID
INNER JOIN SalesLast30Days s ON p.ProductID = s.ProductID
WHERE p.Stock < 50
ORDER BY DaysOfStockRemaining;

Performance Considerations

Index Foreign Keys

-- Always index foreign key columns
CREATE INDEX IX_Products_CategoryID ON Products(CategoryID);
CREATE INDEX IX_OrderItems_OrderID ON OrderItems(OrderID);
CREATE INDEX IX_OrderItems_ProductID ON OrderItems(ProductID);

Join Order Matters (for readability)

-- Start with the "main" table (often the one you're filtering)
SELECT
    o.OrderID,
    p.ProductName
FROM Orders o           -- Main table
INNER JOIN OrderItems oi ON o.OrderID = oi.OrderID
INNER JOIN Products p ON oi.ProductID = p.ProductID
WHERE o.OrderDate >= '2025-01-01';

Filter Early

-- Good: Filter before join
SELECT
    p.ProductName,
    c.CategoryName
FROM (
    SELECT * FROM Products WHERE IsActive = 1
) p
INNER JOIN Categories c ON p.CategoryID = c.CategoryID;

-- Even better: WHERE clause
SELECT
    p.ProductName,
    c.CategoryName
FROM Products p
INNER JOIN Categories c ON p.CategoryID = c.CategoryID
WHERE p.IsActive = 1;

Common Mistakes

1. Unintentional CROSS JOIN

-- Forgot ON clause - becomes CROSS JOIN!
SELECT *
FROM Products, Categories; -- BAD!

-- Should be:
SELECT *
FROM Products p
INNER JOIN Categories c ON p.CategoryID = c.CategoryID;

2. Wrong JOIN Type

-- Using INNER JOIN when you need LEFT JOIN
-- This excludes categories without products
SELECT c.CategoryName, COUNT(p.ProductID)
FROM Categories c
INNER JOIN Products p ON c.CategoryID = p.CategoryID
GROUP BY c.CategoryName;

-- Should use LEFT JOIN to include all categories
SELECT c.CategoryName, COUNT(p.ProductID)
FROM Categories c
LEFT JOIN Products p ON c.CategoryID = p.CategoryID
GROUP BY c.CategoryName;

3. Ambiguous Column Names

-- Error: Ambiguous column name 'CategoryID'
SELECT CategoryID, ProductName
FROM Products p
INNER JOIN Categories c ON p.CategoryID = c.CategoryID;

-- Fix: Prefix with table alias
SELECT p.CategoryID, p.ProductName
FROM Products p
INNER JOIN Categories c ON p.CategoryID = c.CategoryID;

Next Steps

Master joins and you've unlocked the true power of relational databases! Next article covers: - Advanced Reporting: Pivots, ROLLUP, CUBE, and window functions


Practice joining different tables - it's the most important SQL skill!