SQL - Self Join with Examples

A self join is exactly what it sounds like: joining a table to itself. While this might seem circular at first, it's one of the most practical SQL techniques for solving real-world data problems.

Key Insights

  • Self joins treat one table as two separate entities using aliases, enabling comparisons between rows in the same table—essential for hierarchical data like org charts and flight routes.
  • LEFT self joins are critical when you need to include orphan records (like CEOs without managers), while INNER self joins filter to only matching pairs.
  • Always index the columns used in self-join conditions; without proper indexes, self joins can devastate query performance as the table grows.

Introduction to Self Joins

A self join is exactly what it sounds like: joining a table to itself. While this might seem circular at first, it’s one of the most practical SQL techniques for solving real-world data problems.

You need self joins when:

  • Your data has hierarchical relationships (employees reporting to managers)
  • You want to compare rows within the same table (finding duplicates)
  • You’re modeling graph-like structures (flight connections, social networks)
  • You need to find sequential or related records (orders from the same customer)

The key insight is that table aliases let you reference the same physical table as if it were two separate tables. This mental model is crucial—you’re not creating copies, but establishing two different perspectives on the same data.

Understanding the Mechanics

When you write a self join, you give the same table two different aliases. The database engine then treats these as distinct tables for the purpose of the join operation.

SELECT 
    a.column1,
    b.column2
FROM 
    my_table AS a
JOIN 
    my_table AS b ON a.some_column = b.other_column;

Think of it this way: alias a represents “the row I’m looking at” and alias b represents “the row I’m comparing it to.” Without aliases, the query would be ambiguous—the database wouldn’t know which instance of the table you’re referencing.

The join condition defines the relationship between the two perspectives. This could be a foreign key pointing back to the same table’s primary key (hierarchical), a shared attribute (same city), or any other logical relationship.

Classic Use Case: Employee-Manager Hierarchy

The employee-manager relationship is the canonical self join example because it perfectly illustrates the pattern. Each employee has a manager_id that references another row in the same employees table.

CREATE TABLE employees (
    employee_id INT PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    department VARCHAR(50),
    manager_id INT,
    hire_date DATE,
    FOREIGN KEY (manager_id) REFERENCES employees(employee_id)
);

INSERT INTO employees VALUES
(1, 'Sarah Chen', 'Executive', NULL, '2018-01-15'),
(2, 'Marcus Johnson', 'Engineering', 1, '2019-03-22'),
(3, 'Priya Patel', 'Engineering', 2, '2020-06-10'),
(4, 'James Wilson', 'Engineering', 2, '2020-08-05'),
(5, 'Elena Rodriguez', 'Sales', 1, '2019-07-18'),
(6, 'David Kim', 'Sales', 5, '2021-02-28');

Now, to display each employee alongside their manager’s name:

SELECT 
    e.name AS employee_name,
    e.department,
    m.name AS manager_name
FROM 
    employees AS e
INNER JOIN 
    employees AS m ON e.manager_id = m.employee_id;

This returns:

employee_name department manager_name
Marcus Johnson Engineering Sarah Chen
Priya Patel Engineering Marcus Johnson
James Wilson Engineering Marcus Johnson
Elena Rodriguez Sales Sarah Chen
David Kim Sales Elena Rodriguez

Notice Sarah Chen is missing. The INNER JOIN excludes her because her manager_id is NULL—she has no manager to match against.

Comparing Rows Within a Table

Self joins shine when you need to find relationships between rows based on shared attributes. Let’s look at finding customers in the same city:

CREATE TABLE customers (
    customer_id INT PRIMARY KEY,
    name VARCHAR(100),
    city VARCHAR(50),
    signup_date DATE
);

INSERT INTO customers VALUES
(1, 'Acme Corp', 'Chicago', '2023-01-15'),
(2, 'TechStart Inc', 'Chicago', '2023-02-20'),
(3, 'Global Services', 'New York', '2023-01-10'),
(4, 'DataFlow LLC', 'Chicago', '2023-03-05'),
(5, 'Metro Solutions', 'New York', '2023-04-12');
SELECT 
    c1.name AS customer_1,
    c2.name AS customer_2,
    c1.city
FROM 
    customers AS c1
JOIN 
    customers AS c2 ON c1.city = c2.city
WHERE 
    c1.customer_id < c2.customer_id;

The WHERE c1.customer_id < c2.customer_id clause is critical. Without it, you’d get duplicate pairs (Acme-TechStart and TechStart-Acme) plus rows matching each customer to itself. The less-than comparison ensures each pair appears exactly once.

For comparing sequential records—like finding orders placed on the same day by different customers:

SELECT 
    o1.order_id AS order_1,
    o2.order_id AS order_2,
    o1.customer_id AS customer_1,
    o2.customer_id AS customer_2,
    o1.order_date
FROM 
    orders AS o1
JOIN 
    orders AS o2 ON o1.order_date = o2.order_date
WHERE 
    o1.order_id < o2.order_id
    AND o1.customer_id != o2.customer_id;

Self Joins with Different Join Types

The choice between INNER and LEFT self joins depends on whether you need to include unmatched rows.

Going back to our employee hierarchy, using a LEFT JOIN includes Sarah Chen:

SELECT 
    e.name AS employee_name,
    e.department,
    COALESCE(m.name, 'No Manager') AS manager_name
FROM 
    employees AS e
LEFT JOIN 
    employees AS m ON e.manager_id = m.employee_id;

Now the result includes all employees:

employee_name department manager_name
Sarah Chen Executive No Manager
Marcus Johnson Engineering Sarah Chen
Priya Patel Engineering Marcus Johnson
James Wilson Engineering Marcus Johnson
Elena Rodriguez Sales Sarah Chen
David Kim Sales Elena Rodriguez

The COALESCE function handles the NULL gracefully. This pattern is essential for hierarchical data where root nodes (CEOs, top-level categories, root folders) have no parent.

You can also flip the logic to find only orphan records:

SELECT 
    e.name AS top_level_employee,
    e.department
FROM 
    employees AS e
LEFT JOIN 
    employees AS m ON e.manager_id = m.employee_id
WHERE 
    m.employee_id IS NULL;

Performance Considerations

Self joins can become expensive quickly. When you join a table to itself, the database potentially examines n² row combinations. A 10,000-row table could mean 100 million comparisons without proper optimization.

Index the join columns. This is non-negotiable. For hierarchical data:

CREATE INDEX idx_employees_manager_id ON employees(manager_id);

For attribute-based comparisons:

CREATE INDEX idx_customers_city ON customers(city);

Filter early. Apply WHERE conditions that reduce the dataset before the join executes:

-- Better: filter first
SELECT e.name, m.name
FROM employees AS e
JOIN employees AS m ON e.manager_id = m.employee_id
WHERE e.department = 'Engineering';

-- Worse: no early filtering
SELECT e.name, m.name
FROM employees AS e
JOIN employees AS m ON e.manager_id = m.employee_id;

Consider alternatives for deep hierarchies. Self joins handle one level of relationship well. For multi-level traversals (employee → manager → manager’s manager), recursive CTEs are cleaner:

WITH RECURSIVE org_chart AS (
    SELECT employee_id, name, manager_id, 1 AS level
    FROM employees
    WHERE manager_id IS NULL
    
    UNION ALL
    
    SELECT e.employee_id, e.name, e.manager_id, oc.level + 1
    FROM employees e
    JOIN org_chart oc ON e.manager_id = oc.employee_id
)
SELECT * FROM org_chart ORDER BY level, name;

Practical Examples Recap

Here’s a flight connections example that demonstrates self joins for graph-like data:

CREATE TABLE flights (
    flight_id INT PRIMARY KEY,
    origin VARCHAR(3),
    destination VARCHAR(3),
    departure_time TIME,
    arrival_time TIME
);

-- Find connecting flights (arrive at hub, depart from same hub)
SELECT 
    f1.flight_id AS first_leg,
    f1.origin AS start_city,
    f1.destination AS connection_city,
    f2.destination AS final_destination,
    f1.arrival_time AS arrive_connection,
    f2.departure_time AS depart_connection
FROM 
    flights AS f1
JOIN 
    flights AS f2 ON f1.destination = f2.origin
WHERE 
    f2.departure_time > f1.arrival_time
    AND TIMEDIFF(f2.departure_time, f1.arrival_time) >= '01:00:00'
    AND TIMEDIFF(f2.departure_time, f1.arrival_time) <= '04:00:00';

This finds valid connections with a layover between one and four hours—a practical query for any travel application.

Self joins are a fundamental SQL technique that every developer should master. They’re not exotic or rarely used; they solve common problems elegantly. The key is understanding that aliases create separate references to the same data, and the join condition defines how those references relate. Start with the employee-manager pattern, internalize it, and you’ll recognize self join opportunities everywhere.

Liked this? There's more.

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