DEV Community

Cover image for # SQL for Data Analysis: Window Functions, Joins, CTEs and Subqueries
David Gitonga
David Gitonga

Posted on

# SQL for Data Analysis: Window Functions, Joins, CTEs and Subqueries

Introduction

As I continue learning SQL for data analysis, I have come across some concepts that help answer more complex questions than basic SELECT, WHERE, and GROUP BY.

In this article, I will explain four of them using examples from a hospital dataset: Window Functions, Joins, Common Table Expressions (CTEs), and Subqueries.

The goal is to understand what each one does and when to use it in a real situation.

The examples use assumed table and column names. They may need to be adjusted to match the actual hospital dataset.

1. SQL Window Functions

What are Window Functions?

Window functions allow us to calculate values across related rows without removing the individual records.

For example, a hospital analyst may want to rank patients by billing amount while still seeing each patient's record.

Basic Syntax

SELECT
    VisitID,
    Amount,
    SUM(Amount) OVER () AS TotalBilling
FROM Billing;
Enter fullscreen mode Exit fullscreen mode

OVER() tells SQL which set of rows to use for the calculation.

  • PARTITION BY divides rows into groups.
  • ORDER BY determines the order of rows.
  • A window frame determines which rows are included in a calculation.

Example of a running total:

SUM(Amount) OVER (
    ORDER BY VisitID
    ROWS BETWEEN UNBOUNDED PRECEDING
    AND CURRENT ROW
)
Enter fullscreen mode Exit fullscreen mode

This adds the current bill to all the preceding bills in the specified order.

Ranking Functions

1. ROW_NUMBER()

Assigns a unique number to each row.

ROW_NUMBER() OVER (
    PARTITION BY PatientID
    ORDER BY VisitDate
)
Enter fullscreen mode Exit fullscreen mode

This numbers each patient's visits from earliest to latest.

2. RANK()

Ranks records and gives tied values the same rank, leaving gaps afterward.

RANK() OVER (ORDER BY Amount DESC)
Enter fullscreen mode Exit fullscreen mode

This ranks hospital bills from highest to lowest.

3. DENSE_RANK()

Works like RANK(), but does not leave gaps between ranks.

DENSE_RANK() OVER (ORDER BY Amount DESC)
Enter fullscreen mode Exit fullscreen mode

This is useful when ranking patients or departments by billing amount.

4. NTILE()

Divides ordered records into approximately equal groups.

NTILE(4) OVER (ORDER BY Amount DESC)
Enter fullscreen mode Exit fullscreen mode

This divides billing records into four groups for cost analysis.

Navigation Functions

5. LAG()

Returns a value from a previous row.

LAG(VisitDate) OVER (
    PARTITION BY PatientID
    ORDER BY VisitDate
)
Enter fullscreen mode Exit fullscreen mode

This helps identify a patient's previous visit.

6. LEAD()

Returns a value from a following row.

LEAD(VisitDate) OVER (
    PARTITION BY PatientID
    ORDER BY VisitDate
)
Enter fullscreen mode Exit fullscreen mode

This helps compare a visit with the next recorded visit.

7. FIRST_VALUE()

Returns the first value in the specified window.

FIRST_VALUE(VisitDate) OVER (
    PARTITION BY PatientID
    ORDER BY VisitDate
    ROWS BETWEEN UNBOUNDED PRECEDING
    AND UNBOUNDED FOLLOWING
)
Enter fullscreen mode Exit fullscreen mode

This identifies the first recorded visit for each patient.

8. LAST_VALUE()

Returns the last value within the specified window frame.

LAST_VALUE(VisitDate) OVER (
    PARTITION BY PatientID
    ORDER BY VisitDate
    ROWS BETWEEN UNBOUNDED PRECEDING
    AND UNBOUNDED FOLLOWING
)
Enter fullscreen mode Exit fullscreen mode

This displays the latest recorded visit date for each patient.

Aggregate Window Functions

These functions calculate values across rows.

Function What it does Hospital example
SUM() Adds values Total billing
AVG() Calculates the average Average bill
COUNT() Counts rows or values Number of visits
MIN() Finds the smallest value Lowest bill
MAX() Finds the largest value Highest bill

Example:

SELECT
    VisitID,
    Amount,
    AVG(Amount) OVER () AS AverageBill
FROM Billing;
Enter fullscreen mode Exit fullscreen mode

This displays each bill alongside the average bill across the dataset.

The main difference between a normal aggregate and a window aggregate is that GROUP BY usually reduces records to one row per group, while a window function keeps the individual rows.

2. SQL Joins

What are Joins?

Hospital data is usually stored in different tables. For example, patient details may be in Patients, visits in Visits, and charges in Billing.

Joins allow us to combine related records using a common column.

INNER JOIN

Returns only matching records from both tables.

SELECT v.VisitID, b.Amount
FROM Visits AS v
INNER JOIN Billing AS b
    ON v.VisitID = b.VisitID;
Enter fullscreen mode Exit fullscreen mode

Use this when you only need visits with matching billing records.

LEFT JOIN

Returns all records from the left table and matching records from the right.

SELECT p.PatientID, v.VisitID
FROM Patients AS p
LEFT JOIN Visits AS v
    ON p.PatientID = v.PatientID;
Enter fullscreen mode Exit fullscreen mode

This includes patients who have never visited the hospital. Their visit columns will contain NULL.

RIGHT JOIN

Returns all records from the right table and matching records from the left.

SELECT v.VisitID, b.BillID
FROM Visits AS v
RIGHT JOIN Billing AS b
    ON v.VisitID = b.VisitID;
Enter fullscreen mode Exit fullscreen mode

This is useful when every billing record must appear, even if a matching visit is missing.

FULL OUTER JOIN

Returns matching and unmatched records from both tables.

SELECT v.VisitID, b.BillID
FROM Visits AS v
FULL OUTER JOIN Billing AS b
    ON v.VisitID = b.VisitID;
Enter fullscreen mode Exit fullscreen mode

This can help identify missing links during a billing audit. Note that MySQL does not directly support FULL OUTER JOIN.

CROSS JOIN

Returns every possible combination of rows from two tables.

SELECT d.Department, s.ShiftName
FROM Departments AS d
CROSS JOIN Shifts AS s;
Enter fullscreen mode Exit fullscreen mode

This can help generate department and shift combinations for staffing plans.

SELF JOIN

Joins a table to itself.

SELECT
    d.DoctorName AS Doctor,
    m.DoctorName AS Manager
FROM Doctors AS d
LEFT JOIN Doctors AS m
    ON d.ManagerID = m.DoctorID;
Enter fullscreen mode Exit fullscreen mode

This displays each doctor alongside their manager.

Quick Comparison

Join Main purpose
INNER JOIN Only matching records
LEFT JOIN All left-side records
RIGHT JOIN All right-side records
FULL OUTER JOIN All records from both sides
CROSS JOIN Every combination
SELF JOIN Relate records in the same table

One thing I have learned is that joins can increase the number of rows when multiple records match. This is important when calculating billing totals because careless joins can duplicate amounts.

3. Common Table Expressions (CTEs)

What is a CTE?

A CTE is a named query result created using WITH. It makes complex SQL easier to read by breaking it into smaller steps.

Basic CTE

WITH PatientBilling AS (
    SELECT
        PatientID,
        SUM(Amount) AS TotalBilling
    FROM PatientBillingData
    GROUP BY PatientID
)
SELECT *
FROM PatientBilling
WHERE TotalBilling > 5000;
Enter fullscreen mode Exit fullscreen mode

This calculates each patient's total billing and then returns patients whose total exceeds 5,000. PatientBillingData is an illustrative table name.

Multiple CTEs

We can define more than one CTE when a problem requires several calculations.

WITH VisitCounts AS (
    SELECT PatientID, COUNT(*) AS TotalVisits
    FROM Visits
    GROUP BY PatientID
),
BillingTotals AS (
    SELECT v.PatientID, SUM(b.Amount) AS TotalBilling
    FROM Visits AS v
    JOIN Billing AS b
        ON v.VisitID = b.VisitID
    GROUP BY v.PatientID
)
SELECT vc.PatientID, vc.TotalVisits, bt.TotalBilling
FROM VisitCounts AS vc
JOIN BillingTotals AS bt
    ON vc.PatientID = bt.PatientID;
Enter fullscreen mode Exit fullscreen mode

The first CTE counts visits, the second calculates billing totals, and the final query combines them.

Recursive CTEs

Recursive CTEs reference their own results to work through hierarchical data, such as a hospital's doctor-manager structure.

WITH RECURSIVE DoctorHierarchy AS (
    SELECT DoctorID, DoctorName, ManagerID, 0 AS Level
    FROM Doctors
    WHERE ManagerID IS NULL

    UNION ALL

    SELECT d.DoctorID, d.DoctorName,
           d.ManagerID, dh.Level + 1
    FROM Doctors AS d
    JOIN DoctorHierarchy AS dh
        ON d.ManagerID = dh.DoctorID
)
SELECT *
FROM DoctorHierarchy;
Enter fullscreen mode Exit fullscreen mode

The first part identifies the top-level doctors. The recursive part finds the doctors reporting to them and continues through the hierarchy.

This syntax is for PostgreSQL and other databases supporting WITH RECURSIVE. SQL Server uses different recursive CTE syntax.

CTEs with Joins and Aggregations

CTEs can summarize data before joining it to another table.

For example, we can calculate department billing using SUM() and GROUP BY, then join the result to a department table to display department names and totals.

CTEs vs Subqueries

A subquery is a query inside another query. A CTE gives a query a name before the main query uses it.

Both can solve similar problems, but CTEs often make long queries easier to understand and maintain. Neither is automatically faster; performance depends on the database and execution plan.

4. SQL Subqueries

What is a Subquery?

A subquery is a query placed inside another SQL query. It helps us use the result of one calculation to answer another question.

Scalar Subquery

Returns one value.

SELECT VisitID, Amount
FROM Billing
WHERE Amount > (
    SELECT AVG(Amount)
    FROM Billing
);
Enter fullscreen mode Exit fullscreen mode

This returns bills that are higher than the average bill.

Non-Correlated Subquery

Runs independently of the outer query because it does not reference the outer query's columns.

SELECT VisitID, Department
FROM Visits
WHERE Department IN (
    SELECT Department
    FROM Doctors
    WHERE Department = 'Cardiology'
);
Enter fullscreen mode Exit fullscreen mode

This finds visits in a department returned by the inner query.

Correlated Subquery

References a column from the outer query.

SELECT p.PatientID, p.PatientName
FROM Patients AS p
WHERE EXISTS (
    SELECT 1
    FROM Visits AS v
    WHERE v.PatientID = p.PatientID
);
Enter fullscreen mode Exit fullscreen mode

This returns patients who have at least one visit.

Nested Subquery

A subquery placed inside another subquery.

SELECT PatientID
FROM Billing
GROUP BY PatientID
HAVING SUM(Amount) > (
    SELECT AVG(TotalAmount)
    FROM (
        SELECT SUM(Amount) AS TotalAmount
        FROM Billing
        GROUP BY PatientID
    ) AS PatientTotals
);
Enter fullscreen mode Exit fullscreen mode

This example assumes billing records contain PatientID. It compares each patient's total billing with the average of patient-level totals.

IN

Checks whether a value matches any value returned by a subquery.

SELECT PatientID
FROM Visits
WHERE Department IN (
    SELECT Department
    FROM Doctors
    WHERE Department = 'Cardiology'
);
Enter fullscreen mode Exit fullscreen mode

This finds visits in Cardiology when that department is present in the doctor records.

EXISTS

Checks whether a subquery returns at least one row.

SELECT p.PatientID
FROM Patients AS p
WHERE EXISTS (
    SELECT 1
    FROM Visits AS v
    WHERE v.PatientID = p.PatientID
);
Enter fullscreen mode Exit fullscreen mode

This returns patients with recorded visits.

ANY

The comparison needs to be true for at least one value returned by the subquery.

SELECT VisitID, Amount
FROM Billing
WHERE Amount > ANY (
    SELECT Amount
    FROM Billing
    WHERE Amount < 5000
);
Enter fullscreen mode Exit fullscreen mode

This finds bills that exceed at least one bill below 5,000.

ALL

The comparison must be true for every value returned by the subquery.

SELECT VisitID, Amount
FROM Billing
WHERE Amount > ALL (
    SELECT Amount
    FROM Billing
    WHERE Amount < 5000
);
Enter fullscreen mode Exit fullscreen mode

This finds bills that exceed every bill below 5,000.

Subqueries in SELECT, FROM and WHERE

In SELECT: Add a calculated value to each row.

SELECT VisitID,
       (SELECT AVG(Amount) FROM Billing) AS AverageBill
FROM Billing;
Enter fullscreen mode Exit fullscreen mode

In FROM: Create a derived table that can be queried.

SELECT PatientID, TotalBilling
FROM (
    SELECT PatientID, SUM(Amount) AS TotalBilling
    FROM Billing
    GROUP BY PatientID
) AS PatientTotals;
Enter fullscreen mode Exit fullscreen mode

In WHERE: Filter records based on another query's result.

SELECT VisitID, Amount
FROM Billing
WHERE Amount > (SELECT AVG(Amount) FROM Billing);
Enter fullscreen mode Exit fullscreen mode

The main difference is where the result of the subquery is used: as a selected value, as a dataset, or as a filtering condition.

Conclusion

These four SQL concepts help me move beyond basic queries and answer more practical data analysis questions.

  • Window functions help with ranking, comparisons, and calculations across rows.
  • Joins bring related data together from different tables.
  • CTEs make complex queries easier to organize.
  • Subqueries allow one query to use the results of another.

For me, the important part is not just memorizing the syntax. It is understanding the problem first and choosing the SQL technique that solves it.

As I continue working with hospital data, these concepts will help me analyse patient visits, billing, department activity, and missing records more effectively.

SQL #DataAnalytics #DataAnalysis #Database #LearningInPublic

Top comments (0)