PostgreSQL LEFT JOIN vs RIGHT JOIN: Complete Guide
LEFT and RIGHT JOINs are outer joins - they keep all rows from one table and add matching rows from another. If no match exists, columns from the other table are NULL. Understanding when to use each is crucial for writing correct queries.
What You’ll Learn
This guide covers:
- LEFT JOIN: Keeping all left table rows
- RIGHT JOIN: Keeping all right table rows
- NULL handling in outer joins
- When to use LEFT vs RIGHT
- Common business scenarios
- Performance considerations
Understanding the Difference
The key difference is which table’s rows are always kept.
LEFT JOIN keeps all rows from the left (first) table:
SELECT e.emp_id, e.first_name, d.dept_name
FROM employees e
LEFT JOIN departments d ON e.dept_id = d.dept_id;
All employees appear, even if they have no department (dept_name would be NULL).
RIGHT JOIN keeps all rows from the right (second) table:
SELECT e.emp_id, e.first_name, d.dept_name
FROM employees e
RIGHT JOIN departments d ON e.dept_id = d.dept_id;
All departments appear, even if they have no employees (emp_id and first_name would be NULL).
LEFT JOIN Examples
Basic LEFT JOIN
Get all employees and their departments (some employees might have no department).
SELECT
e.emp_id,
e.first_name,
e.last_name,
d.dept_name,
d.location
FROM employees e
LEFT JOIN departments d ON e.dept_id = d.dept_id
ORDER BY e.last_name;
Result (assuming some employees have NULL dept_id):
emp_id | first_name | last_name | dept_name | location
-------+------------+-----------+-----------------+----------
1 | James | Wilson | Sales | New York
2 | Mary | Johnson | Sales | New York
30 | Unknown | Unassigned| (null) | (null)
The key: Row 30 appears even though it has no department. INNER JOIN would exclude it.
LEFT JOIN with WHERE to Find Unmatched Rows
Find all departments that have no employees.
SELECT
d.dept_id,
d.dept_name,
d.location
FROM departments d
LEFT JOIN employees e ON d.dept_id = e.dept_id
WHERE e.emp_id IS NULL
ORDER BY d.dept_name;
The WHERE e.emp_id IS NULL finds rows where the join found no match.
Result:
dept_id | dept_name | location
--------+----------------+-----------
5 | Advanced | Seattle
6 | Research | Boston
This pattern is powerful - use LEFT JOIN then filter for NULL to find unmatched data.
Aggregate LEFT JOIN
Count employees per department (including departments with zero employees).
SELECT
d.dept_id,
d.dept_name,
d.location,
COUNT(e.emp_id) as total_employees,
AVG(e.salary) as avg_salary
FROM departments d
LEFT JOIN employees e ON d.dept_id = e.dept_id
GROUP BY d.dept_id, d.dept_name, d.location
ORDER BY total_employees DESC;
Result:
dept_id | dept_name | location | total_employees | avg_salary
--------+-----------------+---------------+-----------------+------------
4 | Operations | Chicago | 10 | 68,700.00
2 | Engineering | San Francisco | 8 | 97,625.00
3 | Marketing | Los Angeles | 6 | 78,833.33
1 | Sales | New York | 6 | 74,166.67
5 | Advanced | Seattle | 0 | (null)
6 | Research | Boston | 0 | (null)
Compare this to INNER JOIN:
- INNER JOIN: Returns only departments with employees (4 rows)
- LEFT JOIN: Returns all departments, including empty ones (6 rows)
The departments with zero employees have NULL average salary (because AVG of zero rows is NULL).
LEFT JOIN with Multiple Conditions
Get employees and their salary history, including employees with no salary records.
SELECT
e.emp_id,
e.first_name,
e.last_name,
s.salary,
s.effective_date
FROM employees e
LEFT JOIN salaries s ON e.emp_id = s.emp_id
ORDER BY e.last_name, s.effective_date DESC;
Result:
emp_id | first_name | last_name | salary | effective_date
-------+------------+-----------+---------+----------------
1 | James | Wilson | 75000 | 2023-06-01
1 | James | Wilson | 72000 | 2022-01-01
1 | James | Wilson | 70000 | 2020-01-15
2 | Mary | Johnson | 78000 | 2023-01-15
30 | Unknown | Employee | (null) | (null)
Employee 30 appears even with no salary history.
RIGHT JOIN Examples
Basic RIGHT JOIN
Get all departments and their employees.
SELECT
d.dept_id,
d.dept_name,
e.emp_id,
e.first_name,
e.last_name
FROM employees e
RIGHT JOIN departments d ON e.dept_id = d.dept_id
ORDER BY d.dept_name, e.last_name;
Result:
dept_id | dept_name | emp_id | first_name | last_name
--------+-----------------+--------+------------+-----------
1 | Sales | 1 | James | Wilson
1 | Sales | 2 | Mary | Johnson
2 | Engineering | 7 | David | Miller
2 | Engineering | 8 | Jennifer | Martinez
5 | Advanced | (null) | (null) | (null)
6 | Research | (null) | (null) | (null)
All departments appear, with NULL for employees if a department is empty.
RIGHT JOIN vs LEFT JOIN
These queries give identical results - just written differently:
-- Query 1: LEFT JOIN
SELECT e.emp_id, e.first_name, d.dept_name
FROM employees e
LEFT JOIN departments d ON e.dept_id = d.dept_id;
-- Query 2: RIGHT JOIN (equivalent)
SELECT e.emp_id, e.first_name, d.dept_name
FROM departments d
RIGHT JOIN employees e ON d.dept_id = e.dept_id;
Both keep all employee rows and add matching department data. The difference is table order in the FROM/JOIN clauses.
Best Practice: Most developers prefer LEFT JOIN and reverse table order rather than use RIGHT JOIN. It’s clearer which table’s rows are kept.
Instead of:
FROM a RIGHT JOIN b ON ...
Write:
FROM b LEFT JOIN a ON ...
RIGHT JOIN with Filtering
Find departments whose manager is not assigned.
SELECT
d.dept_id,
d.dept_name,
d.manager_id,
m.first_name,
m.last_name
FROM employees m
RIGHT JOIN departments d ON d.manager_id = m.emp_id
WHERE m.emp_id IS NULL
ORDER BY d.dept_name;
Result:
dept_id | dept_name | manager_id | first_name | last_name
--------+----------------+------------+------------+-----------
5 | Advanced | (null) | (null) | (null)
6 | Research | (null) | (null) | (null)
LEFT JOIN vs RIGHT JOIN: When to Use Each
Use LEFT JOIN When:
-
You want all data from the first table
-- All employees, whether or not assigned to a project FROM employees e LEFT JOIN projects p ON e.emp_id = p.assigned_to -
You’re finding unmatched records
-- Products never ordered FROM products p LEFT JOIN orders o ON p.product_id = o.product_id WHERE o.order_id IS NULL -
You’re building reports that include everything
-- All customers and their orders (including those who haven't ordered) FROM customers c LEFT JOIN orders o ON c.customer_id = o.customer_id
Use RIGHT JOIN When:
Honestly, avoid RIGHT JOIN. Almost every use case is clearer with LEFT JOIN by reversing the table order.
Instead of:
-- Avoid: RIGHT JOIN is confusing
SELECT * FROM employees e
RIGHT JOIN departments d ON e.dept_id = d.dept_id;
Write:
-- Better: LEFT JOIN is clearer
SELECT * FROM departments d
LEFT JOIN employees e ON d.dept_id = e.dept_id;
Both do the same thing, but LEFT JOIN makes it obvious which table’s rows are kept.
NULL Handling in Outer Joins
Understanding NULL in Outer Joins
When a match doesn’t exist, columns from the non-kept table are NULL:
SELECT
e.emp_id,
e.first_name,
d.dept_id,
d.dept_name
FROM employees e
LEFT JOIN departments d ON e.dept_id = d.dept_id;
If employee has dept_id = NULL:
e.emp_id,e.first_namehave valuesd.dept_id,d.dept_nameare NULL
Checking for Unmatched Rows
Use IS NULL to find unmatched records:
-- Employees with no department assignment
SELECT e.emp_id, e.first_name
FROM employees e
LEFT JOIN departments d ON e.dept_id = d.dept_id
WHERE d.dept_id IS NULL;
Or check any non-null column:
WHERE e.dept_id IS NULL -- The join column itself
WHERE d.dept_name IS NULL -- Any column from the joined table
COALESCE to Replace NULL
Display meaningful defaults instead of NULL:
SELECT
e.emp_id,
e.first_name,
COALESCE(d.dept_name, 'Unassigned') as department
FROM employees e
LEFT JOIN departments d ON e.dept_id = d.dept_id
ORDER BY e.first_name;
Result:
emp_id | first_name | department
-------+------------+---------------
1 | James | Sales
2 | Mary | Sales
30 | Unknown | Unassigned
CASE Expression for Complex Logic
SELECT
e.first_name,
e.salary,
CASE
WHEN d.dept_name IS NULL THEN 'No Department'
WHEN e.salary < 75000 THEN d.dept_name || ' (Under-compensated)'
ELSE d.dept_name
END as department_status
FROM employees e
LEFT JOIN departments d ON e.dept_id = d.dept_id;
Real-World Business Scenarios
Customer Order Analysis
Find customers who haven’t ordered in the last 30 days.
SELECT
c.customer_id,
c.customer_name,
c.signup_date,
MAX(o.order_date) as last_order_date,
CURRENT_DATE - MAX(o.order_date) as days_since_order
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.customer_name, c.signup_date
HAVING MAX(o.order_date) < CURRENT_DATE - INTERVAL '30 days'
OR MAX(o.order_date) IS NULL
ORDER BY days_since_order DESC;
Finds customers with no recent orders for targeted re-engagement campaigns.
Inventory Management
Products with inventory vs without.
SELECT
p.product_id,
p.product_name,
COALESCE(i.quantity_on_hand, 0) as stock,
COALESCE(i.last_restock_date, 'Never') as restocked
FROM products p
LEFT JOIN inventory i ON p.product_id = i.product_id
WHERE i.quantity_on_hand = 0 OR i.quantity_on_hand IS NULL
ORDER BY p.product_name;
Shows out-of-stock products.
Employee Performance Review Coverage
Employees who haven’t had performance reviews.
SELECT
e.emp_id,
e.first_name,
e.last_name,
COALESCE(MAX(r.review_date), 'No Review') as last_review,
CURRENT_DATE - MAX(r.review_date) as days_since_review
FROM employees e
LEFT JOIN performance_reviews r ON e.emp_id = r.emp_id
GROUP BY e.emp_id, e.first_name, e.last_name
HAVING MAX(r.review_date) IS NULL
OR (CURRENT_DATE - MAX(r.review_date) > INTERVAL '365 days')
ORDER BY e.last_name;
Identifies employees needing reviews.
Common Errors & Solutions
Error: Unexpected NULL values in results
Problem:
SELECT e.first_name, d.dept_name
FROM employees e
LEFT JOIN departments d ON e.dept_id = d.dept_id
WHERE d.dept_name = 'Engineering';
Expected to see all employees, but get fewer rows.
Explanation:
The WHERE clause filters out rows where d.dept_name is NULL. The LEFT JOIN keeps all employees, but WHERE then removes employees with no department.
Solution:
Move the condition to ON:
SELECT e.first_name, d.dept_name
FROM employees e
LEFT JOIN departments d ON e.dept_id = d.dept_id
AND d.dept_name = 'Engineering';
Now the condition affects which department rows are joined (not filtering results).
Error: “right side of join doesn’t match left side”
Problem:
-- Unbalanced NULL handling
SELECT COUNT(e.emp_id), COUNT(d.dept_id)
FROM employees e
LEFT JOIN departments d ON e.dept_id = d.dept_id;
Gets different counts if some employees have no department.
Solution:
This isn’t actually an error - it’s the expected behavior. Just understand:
COUNT(e.emp_id) as employees_total, -- All 30 employees
COUNT(d.dept_id) as matched_departments -- Fewer if some have no dept
If you want total rows returned:
COUNT(*) as total_rows -- Equals COUNT(e.emp_id) for LEFT JOIN
Performance Issue: LEFT JOIN causing slow queries
Problem:
SELECT e.*, d.*, p.*
FROM employees e
LEFT JOIN departments d ON e.dept_id = d.dept_id
LEFT JOIN projects p ON d.dept_id = p.dept_id;
Query is slow and returns many duplicate rows.
Solution:
-
Select only needed columns:
SELECT e.emp_id, e.first_name, d.dept_name, p.project_name -
Use EXPLAIN to check join performance:
EXPLAIN ANALYZE SELECT ... -
Ensure indexes exist on join columns:
CREATE INDEX idx_employees_dept_id ON employees(dept_id);
Performance Tips
1. LEFT JOIN is Slower Than INNER JOIN
Only use LEFT JOIN when you need unmatched rows:
-- Fast: INNER JOIN (all employees have departments)
SELECT * FROM employees e
INNER JOIN departments d ON e.dept_id = d.dept_id;
-- Slower: LEFT JOIN (unnecessary if no employees lack departments)
SELECT * FROM employees e
LEFT JOIN departments d ON e.dept_id = d.dept_id;
2. Index Join Columns
CREATE INDEX idx_employees_dept_id ON employees(dept_id);
CREATE INDEX idx_departments_dept_id ON departments(dept_id);
3. Use IS NULL Checks Efficiently
-- Faster: PostgreSQL optimizes IS NULL with indexes
WHERE d.dept_id IS NULL;
-- Slower: Full table scan
WHERE d.dept_id = NULL; -- This doesn't work in SQL!
4. EXPLAIN ANALYZE LEFT JOINs
EXPLAIN ANALYZE
SELECT * FROM employees e
LEFT JOIN departments d ON e.dept_id = d.dept_id;
Look for “Hash Join” or “Merge Join” in output.
FAQ
Q: Should I use LEFT or RIGHT JOIN? A: Prefer LEFT JOIN by reversing table order. It’s clearer which table’s rows are kept.
Q: What does NULL mean in a LEFT JOIN? A: The join found no matching row in the right table for this row from the left table.
Q: Can I use WHERE to filter left table rows in an outer join? A: Yes, but conditions on the left table work. Conditions on the right table should go in ON, not WHERE.
Q: How do I find unmatched rows?
A: Use LEFT/RIGHT JOIN then WHERE right_column IS NULL.
Q: Does LEFT JOIN always return more rows than INNER JOIN? A: Not necessarily the same rows, but it returns all left table rows. INNER JOIN returns only matching rows.
Q: Can I LEFT JOIN the same table multiple times?
A: Yes, useful for hierarchies: FROM e1 LEFT JOIN e2 ON e1.manager_id = e2.emp_id LEFT JOIN e3 ON e2.manager_id = e3.emp_id
Related Topics
Continue learning about joins:
- PostgreSQL JOINs Complete Guide - All JOIN types
- INNER JOIN Examples - Practical INNER JOIN patterns
- Multiple JOINs in Single Query - Chaining joins
- Complex JOIN Conditions - Advanced patterns
LEFT and RIGHT JOINs are essential for reports and finding gaps in data. Master them and you’ll write more accurate queries.