How to Use SQL Joins and Conditional Statements in PostgreSQL

6 min read

If you’re working with PostgreSQL and need to pull data from multiple tables, you’ll need SQL joins. And when you want to add logic to your queries — like categorizing values or handling nulls — that’s where CASE statements come in. This post covers both with practical examples you can run directly in psql.

We’ll set up sample tables, insert some data, and then walk through each join type and conditional pattern with expected output so you can see exactly what each query returns.

Prerequisites

Setting Up Sample Data

First, create three tables we’ll use throughout the examples: employees, departments, and bonuses.

CREATE TABLE departments (
    department_id INT PRIMARY KEY,
    department_name VARCHAR(50)
);

CREATE TABLE employees (
    employee_id INT PRIMARY KEY,
    name VARCHAR(50),
    department_id INT,
    salary INT
);

CREATE TABLE bonuses (
    employee_id INT,
    bonus_amount INT
);

Now insert some data. Notice that not every employee has a bonus, and not every department has employees — this is intentional so the join examples produce meaningful results.

INSERT INTO departments VALUES
    (1, 'Engineering'),
    (2, 'Marketing'),
    (3, 'Sales'),
    (4, 'HR');

INSERT INTO employees VALUES
    (101, 'Alice', 1, 75000),
    (102, 'Bob', 1, 65000),
    (103, 'Carol', 2, 55000),
    (104, 'Dave', 3, 45000),
    (105, 'Eve', NULL, 40000);

INSERT INTO bonuses VALUES
    (101, 5000),
    (103, 3000),
    (106, 2000);

Key things about this data:

  • Eve (105) has no department (NULL)
  • HR department (4) has no employees
  • Employee 106 in the bonuses table doesn’t exist in the employees table

These gaps are what make joins interesting.

SQL Joins in PostgreSQL

INNER JOIN

Returns only rows that have a match in both tables. If there’s no match, the row is excluded.

SELECT e.name, d.department_name
FROM employees e
INNER JOIN departments d ON e.department_id = d.department_id;

Result:

 name  | department_name
-------+-----------------
 Alice | Engineering
 Bob   | Engineering
 Carol | Marketing
 Dave  | Sales

Eve is missing because her department_id is NULL — there’s no matching department. HR is also missing because no employee belongs to it. That’s the thing with INNER JOIN: both sides need a match.

LEFT JOIN

Returns all rows from the left table, and matching rows from the right table. If there’s no match on the right side, you get NULL.

SELECT e.name, d.department_name
FROM employees e
LEFT JOIN departments d ON e.department_id = d.department_id;
 name  | department_name
-------+-----------------
 Alice | Engineering
 Bob   | Engineering
 Carol | Marketing
 Dave  | Sales
 Eve   | NULL

Eve shows up now with NULL for department — the left table (employees) is fully preserved. This is probably the join you’ll use most often. Anytime you want “give me all records from this table, plus any matching info from that other table,” LEFT JOIN is what you want.

RIGHT JOIN

The opposite of LEFT JOIN — returns all rows from the right table, with matching data from the left.

SELECT e.name, d.department_name
FROM employees e
RIGHT JOIN departments d ON e.department_id = d.department_id;
 name  | department_name
-------+-----------------
 Alice | Engineering
 Bob   | Engineering
 Carol | Marketing
 Dave  | Sales
 NULL  | HR

HR appears with NULL for the employee name since no one is assigned to it. Eve is gone because she’s on the left side with no match. In practice, most people just flip the table order and use LEFT JOIN instead of RIGHT JOIN — it reads more naturally.

FULL OUTER JOIN

Returns all rows from both tables. Where there’s no match on either side, you get NULL.

SELECT e.name, d.department_name
FROM employees e
FULL OUTER JOIN departments d ON e.department_id = d.department_id;
 name  | department_name
-------+-----------------
 Alice | Engineering
 Bob   | Engineering
 Carol | Marketing
 Dave  | Sales
 Eve   | NULL
 NULL  | HR

Both Eve and HR show up. You don’t use FULL OUTER JOIN as often, but it’s useful when you need to find unmatched records on both sides — like reconciling data between two systems.

Quick Reference

Join Type What It Returns
INNER JOIN Only matching rows from both tables
LEFT JOIN All left table rows + matching right rows
RIGHT JOIN All right table rows + matching left rows
FULL OUTER JOIN All rows from both tables

Joining More Than Two Tables

You can chain multiple joins. Here’s how to get employee names with their department and bonus in one query:

SELECT
    e.name,
    d.department_name,
    b.bonus_amount
FROM employees e
LEFT JOIN departments d ON e.department_id = d.department_id
LEFT JOIN bonuses b ON e.employee_id = b.employee_id;
 name  | department_name | bonus_amount
-------+-----------------+--------------
 Alice | Engineering     | 5000
 Bob   | Engineering     | NULL
 Carol | Marketing       | 3000
 Dave  | Sales           | NULL
 Eve   | NULL            | NULL

LEFT JOIN on both so we keep all employees, even if they don’t have a department or a bonus.

Conditional Statements with CASE

The CASE statement works like if/else logic inside your SQL query. You use it to create computed columns, categorize data, or handle conditional formatting right in the query.

Basic CASE Statement

Categorize employees by salary range:

SELECT
    name,
    salary,
    CASE
        WHEN salary >= 70000 THEN 'High'
        WHEN salary >= 50000 THEN 'Medium'
        ELSE 'Low'
    END AS salary_level
FROM employees;
 name  | salary | salary_level
-------+--------+--------------
 Alice | 75000  | High
 Bob   | 65000  | Medium
 Carol | 55000  | Medium
 Dave  | 45000  | Low
 Eve   | 40000  | Low

The CASE evaluates conditions top to bottom and returns the first match. The ELSE catches everything that didn’t match any WHEN clause.

CASE with NULL Handling

A common real-world pattern — labeling rows that have missing data:

SELECT
    name,
    CASE
        WHEN department_id IS NULL THEN 'Unassigned'
        ELSE 'Assigned'
    END AS assignment_status
FROM employees;
 name  | assignment_status
-------+-------------------
 Alice | Assigned
 Bob   | Assigned
 Carol | Assigned
 Dave  | Assigned
 Eve   | Unassigned

Using COALESCE for NULL Defaults

If you just need to replace NULL with a default value, COALESCE is shorter than writing a full CASE block:

SELECT
    e.name,
    COALESCE(b.bonus_amount, 0) AS bonus
FROM employees e
LEFT JOIN bonuses b ON e.employee_id = b.employee_id;
 name  | bonus
-------+-------
 Alice | 5000
 Bob   | 0
 Carol | 3000
 Dave  | 0
 Eve   | 0

COALESCE returns the first non-NULL value from its arguments. You’ll use this a lot when working with LEFT JOINs since unmatched rows come back as NULL.

Combining Joins and CASE

This is where it gets practical. Let’s build a query that brings everything together — employee info, department, bonus, and a computed column:

SELECT
    e.name,
    COALESCE(d.department_name, 'No Department') AS department,
    e.salary,
    COALESCE(b.bonus_amount, 0) AS bonus,
    e.salary + COALESCE(b.bonus_amount, 0) AS total_compensation,
    CASE
        WHEN e.salary + COALESCE(b.bonus_amount, 0) >= 70000 THEN 'Senior'
        WHEN e.salary + COALESCE(b.bonus_amount, 0) >= 50000 THEN 'Mid'
        ELSE 'Junior'
    END AS level
FROM employees e
LEFT JOIN departments d ON e.department_id = d.department_id
LEFT JOIN bonuses b ON e.employee_id = b.employee_id
ORDER BY total_compensation DESC;
 name  | department    | salary | bonus | total_compensation | level
-------+---------------+--------+-------+--------------------+--------
 Alice | Engineering   | 75000  | 5000  | 80000              | Senior
 Bob   | Engineering   | 65000  | 0     | 65000              | Mid
 Carol | Marketing     | 55000  | 3000  | 58000              | Mid
 Dave  | Sales         | 45000  | 0     | 45000              | Junior
 Eve   | No Department | 40000  | 0     | 40000              | Junior

This query joins three tables, handles NULLs with COALESCE, creates a computed column for total compensation, and categorizes each employee with a CASE statement. This kind of pattern shows up constantly in reporting queries.

CASE in WHERE and ORDER BY

CASE isn’t limited to the SELECT clause. You can use it in ORDER BY to create custom sort orders:

SELECT name, salary
FROM employees
ORDER BY
    CASE
        WHEN department_id = 1 THEN 1
        WHEN department_id = 3 THEN 2
        ELSE 3
    END;

This puts Engineering first, then Sales, then everyone else — without changing the data itself.

You can also use it in aggregate functions to do conditional counting:

SELECT
    COUNT(*) AS total_employees,
    COUNT(CASE WHEN salary >= 50000 THEN 1 END) AS above_50k,
    COUNT(CASE WHEN salary < 50000 THEN 1 END) AS below_50k
FROM employees;
 total_employees | above_50k | below_50k
-----------------+-----------+-----------
               5 |         3 |         2

This is a handy trick to avoid writing separate queries for each count.

Cleanup

When you’re done experimenting, drop the tables:

DROP TABLE bonuses;
DROP TABLE employees;
DROP TABLE departments;

Conclusion

Joins and CASE statements are the two things you’ll reach for the most when writing anything beyond simple SELECT queries. Once you’re comfortable combining LEFT JOINs with COALESCE and CASE, you can handle most reporting and data extraction tasks without needing anything more complex.

If you don’t have PostgreSQL set up yet, check How to Install PostgreSQL on WSL Ubuntu 22.04. For a GUI to run these queries more comfortably, How to Access PostgreSQL in Ubuntu WSL with pgAdmin 4 walks you through setting up pgAdmin.