SQL - LEFT JOIN (LEFT OUTER JOIN)

LEFT JOIN (also called LEFT OUTER JOIN) is one of the most frequently used JOIN operations in SQL. It returns all records from the left table and the matched records from the right table. When no...

Key Insights

  • LEFT JOIN returns every row from the left table regardless of whether a match exists in the right table, filling unmatched columns with NULL values
  • The most powerful use case for LEFT JOIN is finding records that don’t have corresponding entries in another table using the WHERE right_table.column IS NULL pattern
  • JOIN column order matters for readability and performance—always join on indexed columns and consider the logical flow of your data relationships

Introduction to LEFT JOIN

LEFT JOIN (also called LEFT OUTER JOIN) is one of the most frequently used JOIN operations in SQL. It returns all records from the left table and the matched records from the right table. When no match exists, the result contains NULL values for columns from the right table.

This behavior makes LEFT JOIN essential when you need complete data from one table while optionally enriching it with related data from another. Think of it as saying: “Give me everything from table A, and if there’s matching data in table B, include that too.”

The key distinction from INNER JOIN is preservation. INNER JOIN discards rows without matches. LEFT JOIN preserves them. This difference determines which operation you reach for based on your requirements.

LEFT JOIN Syntax

The standard SQL syntax for LEFT JOIN is straightforward:

SELECT columns
FROM left_table
LEFT JOIN right_table
    ON left_table.column = right_table.column;

Breaking this down:

  • left_table is the primary table whose rows you want to preserve
  • right_table is the secondary table you’re joining
  • The ON clause specifies the join condition (typically matching foreign keys to primary keys)
  • LEFT OUTER JOIN and LEFT JOIN are interchangeable—most developers use the shorter form

The table listed immediately after FROM is always the left table. The table after LEFT JOIN is the right table. This ordering is crucial because it determines which table’s rows are guaranteed to appear in the result.

-- These are equivalent
SELECT * FROM employees LEFT JOIN departments ON employees.dept_id = departments.id;
SELECT * FROM employees LEFT OUTER JOIN departments ON employees.dept_id = departments.id;

How LEFT JOIN Works

Understanding the matching process helps you predict query results and debug unexpected output.

When the database executes a LEFT JOIN:

  1. It takes each row from the left table
  2. It searches for matching rows in the right table based on the ON condition
  3. If matches exist, it creates result rows combining data from both tables
  4. If no match exists, it creates a result row with left table data and NULLs for right table columns

Let’s see this with concrete data:

-- Create sample tables
CREATE TABLE employees (
    id INT PRIMARY KEY,
    name VARCHAR(100),
    dept_id INT
);

CREATE TABLE departments (
    id INT PRIMARY KEY,
    dept_name VARCHAR(100)
);

-- Insert sample data
INSERT INTO employees VALUES
    (1, 'Alice', 10),
    (2, 'Bob', 20),
    (3, 'Charlie', 30),
    (4, 'Diana', NULL);  -- No department assigned

INSERT INTO departments VALUES
    (10, 'Engineering'),
    (20, 'Marketing'),
    (40, 'Finance');  -- No employees in this department

-- LEFT JOIN query
SELECT 
    e.id AS emp_id,
    e.name,
    e.dept_id,
    d.dept_name
FROM employees e
LEFT JOIN departments d ON e.dept_id = d.id;

Result:

emp_id name dept_id dept_name
1 Alice 10 Engineering
2 Bob 20 Marketing
3 Charlie 30 NULL
4 Diana NULL NULL

Notice that Charlie (dept_id 30) and Diana (no dept_id) both appear in the results despite having no matching department. The Finance department (id 40) doesn’t appear because it’s in the right table and has no matching employees.

LEFT JOIN vs INNER JOIN

The difference becomes crystal clear when you run both operations on the same data:

-- INNER JOIN: Only matched rows
SELECT 
    e.name,
    d.dept_name
FROM employees e
INNER JOIN departments d ON e.dept_id = d.id;

-- Result:
-- | name  | dept_name   |
-- |-------|-------------|
-- | Alice | Engineering |
-- | Bob   | Marketing   |

-- LEFT JOIN: All left rows, matched or not
SELECT 
    e.name,
    d.dept_name
FROM employees e
LEFT JOIN departments d ON e.dept_id = d.id;

-- Result:
-- | name    | dept_name   |
-- |---------|-------------|
-- | Alice   | Engineering |
-- | Bob     | Marketing   |
-- | Charlie | NULL        |
-- | Diana   | NULL        |

INNER JOIN returned 2 rows. LEFT JOIN returned 4 rows. The difference is the unmatched employees.

Use INNER JOIN when you only want records that exist in both tables. Use LEFT JOIN when you need all records from the primary table regardless of matches.

A common mistake is using LEFT JOIN when you actually need INNER JOIN, then wondering why you’re getting NULL values in your results. Be intentional about which operation you choose.

Common Use Cases

Finding Unmatched Records

The most powerful LEFT JOIN pattern is finding records without corresponding entries in another table:

-- Find customers who have never placed an order
SELECT 
    c.id,
    c.name,
    c.email
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
WHERE o.id IS NULL;

This pattern works because unmatched rows have NULL in all right-table columns. By filtering for WHERE right_table.primary_key IS NULL, you isolate records that exist only in the left table.

Optional Relationships

When relationships aren’t mandatory, LEFT JOIN preserves your primary data:

-- Get all products with their optional discount information
SELECT 
    p.name,
    p.price,
    COALESCE(d.percentage, 0) AS discount_percent,
    p.price * (1 - COALESCE(d.percentage, 0) / 100) AS final_price
FROM products p
LEFT JOIN discounts d ON p.id = d.product_id 
    AND d.valid_until >= CURRENT_DATE;

Complete Reporting Lists

Reports often need complete lists even when related data is sparse:

-- Monthly sales report showing all months, even those with no sales
SELECT 
    m.month_name,
    COALESCE(SUM(s.amount), 0) AS total_sales,
    COUNT(s.id) AS transaction_count
FROM months m
LEFT JOIN sales s ON MONTH(s.sale_date) = m.month_number
    AND YEAR(s.sale_date) = 2024
GROUP BY m.month_name, m.month_number
ORDER BY m.month_number;

Multiple LEFT JOINs and Complex Queries

Real-world queries often chain multiple LEFT JOINs together:

-- Comprehensive order report with customer and product details
SELECT 
    c.name AS customer_name,
    c.email,
    o.id AS order_id,
    o.order_date,
    p.name AS product_name,
    oi.quantity,
    oi.unit_price,
    (oi.quantity * oi.unit_price) AS line_total
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
LEFT JOIN order_items oi ON o.id = oi.order_id
LEFT JOIN products p ON oi.product_id = p.id
WHERE c.created_at >= '2024-01-01'
ORDER BY c.name, o.order_date;

When combining LEFT JOIN with aggregation, be mindful of NULL handling:

-- Customer summary with order statistics
SELECT 
    c.id,
    c.name,
    COUNT(o.id) AS order_count,  -- COUNT ignores NULLs
    COALESCE(SUM(o.total_amount), 0) AS lifetime_value,
    MAX(o.order_date) AS last_order_date
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
GROUP BY c.id, c.name
ORDER BY lifetime_value DESC;

Note that COUNT(o.id) correctly returns 0 for customers with no orders because COUNT excludes NULL values. Using COUNT(*) would return 1 for those customers, which is incorrect.

Performance Considerations

LEFT JOIN can impact query performance, especially with large tables. Here are practical optimizations:

Index Your Join Columns

Always ensure indexes exist on columns used in JOIN conditions:

-- Create indexes for common join patterns
CREATE INDEX idx_orders_customer_id ON orders(customer_id);
CREATE INDEX idx_order_items_order_id ON order_items(order_id);
CREATE INDEX idx_order_items_product_id ON order_items(product_id);

-- Composite index for joins with additional filtering
CREATE INDEX idx_orders_customer_date ON orders(customer_id, order_date);

Filter Early When Possible

Apply WHERE conditions to the left table before joining:

-- Less efficient: Filter after join
SELECT c.name, o.total_amount
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
WHERE c.status = 'active';

-- More efficient: Use subquery or CTE to filter first
WITH active_customers AS (
    SELECT id, name FROM customers WHERE status = 'active'
)
SELECT ac.name, o.total_amount
FROM active_customers ac
LEFT JOIN orders o ON ac.id = o.customer_id;

Consider Query Structure

Sometimes restructuring your query eliminates unnecessary LEFT JOINs:

-- If you only need existence checks, EXISTS can be faster
SELECT c.name
FROM customers c
WHERE NOT EXISTS (
    SELECT 1 FROM orders o WHERE o.customer_id = c.id
);

-- Compare to LEFT JOIN approach
SELECT c.name
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
WHERE o.id IS NULL;

The query optimizer often handles these equivalently, but testing with your specific data and database engine reveals which performs better.

LEFT JOIN is a foundational SQL operation that every developer should master. Understanding when rows are preserved versus discarded, how NULL values propagate, and how to combine LEFT JOIN with filtering and aggregation will serve you in virtually every database-driven application you build.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.