DEV Community

Cover image for SQL Window Functions, Joins, CTEs and Subqueries: A Practical Guide Using a Hospital Dataset
Adrian Mageto
Adrian Mageto

Posted on

SQL Window Functions, Joins, CTEs and Subqueries: A Practical Guide Using a Hospital Dataset

Introduction

Most SQL tutorials stop at SELECT, WHERE and GROUP BY. Real analysis needs more: ranking patients, comparing a visit with the previous one, combining several tables, and breaking a hard question into steps. This article covers four tools that make that possible:

  1. Window functions: calculations across related rows without collapsing them.
  2. Joins: combining tables.
  3. Common Table Expressions (CTEs): naming intermediate results.
  4. Subqueries: queries nested inside other queries.

Every example uses one hospital database (PostgreSQL syntax), and every result shown comes from running the query on that data.

The hospital dataset

The database lives in a schema called morning and has a star-style design: one fact table (visits) and several dimension tables.

Table What it holds Rows
patients patient_id, name, gender, age, county, sub_county 25
doctors doctor_id, name, gender, specialty, experience years 4
department department_id, department_name 4
wards ward_id, ward_name 4
diagnosis diagnosis_id, name, category 18
procedures procedure_id, name, category 15
visits one row per visit: date, patient, doctor, department, ward, diagnosis, procedure, admission_type, length_of_stay_days, triage_level, insurance_provider, total_bill_kes, payment_method, follow_up_required, patient_satisfaction_score 30

Before running anything:

set search_path to morning;
Enter fullscreen mode Exit fullscreen mode

Data quality notes. While working with the data I noticed a few things that affect grouping and joining: diagnosis names such as Back Pian and the category Respiratoey Disease contain typos; Infectious and Infectious Disease are two spellings of one category; one county is stored with a leading space (Kiambu); and diagnoses 513 to 518 were missing from the original list and were added with suggested names. In real work you would clean these first, because GROUP BY treats each spelling as a separate group.


Part 1: Window Functions

What is a window function?

An aggregate with GROUP BY collapses many rows into one. A window function performs a calculation across a set of related rows (the window) but keeps every original row. That lets you show a visit and, on the same row, its rank, the previous bill, or a running total.

The OVER() clause

Every window function is followed by OVER (...), which defines the window:

function_name(...) OVER (
    PARTITION BY column   -- split rows into groups (optional)
    ORDER BY column       -- order rows inside each group (optional)
    frame_clause          -- which rows around the current row to use (optional)
)
Enter fullscreen mode Exit fullscreen mode
  • OVER () with nothing inside means the window is the whole result set.
  • PARTITION BY restarts the calculation for each group, like GROUP BY but without collapsing rows. Partition by doctor_id and each doctor gets their own ranking.
  • ORDER BY inside OVER decides the order the function sees rows in. It matters for ranking, LAG/LEAD, and running totals. It is separate from the ORDER BY at the end of the query, which only sorts the final output.
  • Window frame says exactly which rows, relative to the current row, are included (covered in detail below).

1. ROW_NUMBER()

What it does: gives each row in a partition a unique number, 1, 2, 3, and so on, following the ORDER BY. Even tied rows get different numbers.

Why use it: numbering visits, picking the latest record per patient, removing duplicates.

Hospital scenario: number each patient's visits in date order, for the five patients who came back more than once.

select visit_id, patient_id, visit_date, total_bill_kes,
       row_number() over (partition by patient_id order by visit_date) as visit_number
from visits
where patient_id in (1, 2, 5, 7, 10)
order by patient_id, visit_date;
Enter fullscreen mode Exit fullscreen mode

What it does: it splits the rows into one group per patient, sorts each group by date, and numbers them from 1.

visit_id patient_id visit_date total_bill_kes visit_number
1001 1 2026-06-01 3500 1
1011 1 2026-06-06 4200 2
1002 2 2026-06-01 2500 1
1021 2 2026-06-11 3800 2
1005 5 2026-06-03 4000 1
1025 5 2026-06-13 7600 2
1007 7 2026-06-04 5500 1
1028 7 2026-06-14 1900 2
1010 10 2026-06-05 76000 1
1030 10 2026-06-15 22000 2

A very common pattern: the latest visit per patient. You cannot filter on a window function directly in WHERE, so wrap it in a CTE (explained in Part 3):

with ranked as (
    select visit_id, patient_id, visit_date,
           row_number() over (partition by patient_id order by visit_date desc, visit_id desc) as rn
    from visits
)
select visit_id, patient_id, visit_date
from ranked
where rn = 1;
Enter fullscreen mode Exit fullscreen mode

This returns exactly one row per patient (25 rows), the most recent visit of each.

2. RANK() and 3. DENSE_RANK() (compared with ROW_NUMBER)

These three functions all rank rows, but they handle ties differently:

Function Ties get After a tie
ROW_NUMBER() different numbers (arbitrary unless you add a tie-breaker) continues 1, 2, 3, 4
RANK() the same rank skips numbers (1, 2, 2, 4)
DENSE_RANK() the same rank no gaps (1, 2, 2, 3)

Hospital scenario: rank inpatient visits by length of stay, longest first. Several patients stayed the same number of days, so ties appear.

select visit_id, patient_id, length_of_stay_days as stay,
       row_number() over (order by length_of_stay_days desc, visit_id) as row_num,
       rank()       over (order by length_of_stay_days desc)           as rnk,
       dense_rank() over (order by length_of_stay_days desc)           as dense_rnk
from visits
where admission_type = 'Inpatient'
order by row_num;
Enter fullscreen mode Exit fullscreen mode
visit_id patient_id stay row_num rnk dense_rnk
1017 16 6 1 1 1
1010 10 5 2 2 2
1004 4 4 3 3 3
1003 3 3 4 4 4
1016 15 3 5 4 4
1015 14 2 6 6 5
1024 22 2 7 6 5
1026 23 2 8 6 5
1027 24 1 9 9 6

Reading the result: visits 1003 and 1016 both stayed 3 days, so RANK gives both rank 4, and the next group starts at rank 6, because positions 4 and 5 were used. DENSE_RANK gives the next group rank 5 with no gap. ROW_NUMBER simply counts 1 to 9.

When to use which: ROW_NUMBER when you need exactly one row per group (de-duplication). RANK for competition-style ranking where gaps are meaningful. DENSE_RANK for 'top N levels' questions, such as 'the three longest distinct stay lengths'.

4. NTILE(n)

What it does: divides the ordered rows into n roughly equal buckets and tells you which bucket each row falls in. If the rows do not divide evenly, the first buckets get one extra row.

Why use it: quartiles, percentile-style bands, segmenting customers or patients into tiers.

Hospital scenario: split all 30 visits into four billing quartiles and see the bill range of each.

with t as (
    select visit_id, total_bill_kes,
           ntile(4) over (order by total_bill_kes) as quartile
    from visits
)
select quartile, count(*) as visits, min(total_bill_kes) as lowest, max(total_bill_kes) as highest
from t
group by quartile
order by quartile;
Enter fullscreen mode Exit fullscreen mode
quartile visits lowest highest
1 8 1800 3500
2 8 3800 6500
3 7 7000 24000
4 7 28000 82000

Thirty rows cannot split into four equal groups, so the first two quartiles hold 8 rows and the last two hold 7. The top quartile (bills of KES 28,000 and above) is where most of the hospital's revenue sits.

5. LAG() and 6. LEAD()

What they do: LAG(col) returns the value of col from the previous row in the window; LEAD(col) returns it from the next row. The first row of a partition has no previous row, so LAG returns NULL there; the last row gets NULL from LEAD. An optional second argument sets the offset and a third a default (LAG(col, 2, 0)).

Why use them: comparing a record with its neighbours, such as change since last visit, time between events, month-over-month growth, without a self join.

Hospital scenario: for returning patients, show how the bill changed compared with their previous visit, and the date of the next visit.

select patient_id, visit_id, visit_date, total_bill_kes,
       lag(total_bill_kes) over w                       as previous_bill,
       total_bill_kes - lag(total_bill_kes) over w      as bill_change,
       lead(visit_date) over w                          as next_visit
from visits
where patient_id in (1, 2, 5, 7, 10)
window w as (partition by patient_id order by visit_date)
order by patient_id, visit_date;
Enter fullscreen mode Exit fullscreen mode

The WINDOW w AS (...) clause names a window definition once so several functions can reuse it.

patient_id visit_id visit_date total_bill_kes previous_bill bill_change next_visit
1 1001 2026-06-01 3500 NULL NULL 2026-06-06
1 1011 2026-06-06 4200 3500 700 NULL
5 1005 2026-06-03 4000 NULL NULL 2026-06-13
5 1025 2026-06-13 7600 4000 3600 NULL
7 1007 2026-06-04 5500 NULL NULL 2026-06-14
7 1028 2026-06-14 1900 5500 -3600 NULL
10 1010 2026-06-05 76000 NULL NULL 2026-06-15
10 1030 2026-06-15 22000 76000 -54000 NULL

(Patient 2 follows the same pattern: 2500 then 3800, a change of 1300.) Patient 10's bill dropped by KES 54,000: the first visit was a five-day inpatient stay for heart failure, the second a one-day emergency visit for chest pain. The change per visit tells a story that a plain SUM would hide.

7. FIRST_VALUE() and 8. LAST_VALUE()

What they do: return the first or last value of the window frame. This is where window frames matter most, so read the warning below.

Hospital scenario: for Dr. Faith Chebet (doctor 104), compare each visit's bill with her first and last bill.

select visit_id, visit_date, total_bill_kes,
       first_value(total_bill_kes) over w as first_bill,
       last_value(total_bill_kes)  over w as last_bill_default,
       last_value(total_bill_kes)  over (
           partition by doctor_id order by visit_date, visit_id
           rows between unbounded preceding and unbounded following
       ) as last_bill_correct
from visits
where doctor_id = 104
window w as (partition by doctor_id order by visit_date, visit_id)
order by visit_date, visit_id;
Enter fullscreen mode Exit fullscreen mode
visit_id visit_date total_bill_kes first_bill last_bill_default last_bill_correct
1004 2026-06-02 42000 42000 42000 1900
1007 2026-06-04 5500 42000 5500 1900
1014 2026-06-07 1800 42000 1800 1900
1019 2026-06-10 5200 42000 5200 1900
1024 2026-06-12 18500 42000 18500 1900
1028 2026-06-14 1900 42000 1900 1900

The trap: when a window has an ORDER BY and no explicit frame, the default frame runs from the start of the partition to the current row. So LAST_VALUE with the default frame just returns the current row's own value (the last_bill_default column), which is almost never what you want. Extending the frame to UNBOUNDED FOLLOWING makes it see the real last row (last_bill_correct). FIRST_VALUE is unaffected because the start of the frame is always the partition start.

9. Aggregate window functions: SUM, AVG, COUNT, MIN, MAX

The familiar aggregates become window functions when you add OVER. Without ORDER BY they cover the whole partition on every row; with ORDER BY they become running calculations.

Hospital scenario: show each department's totals while keeping the context of every visit. Here SELECT DISTINCT is used to display one line per department.

select distinct department_id,
       sum(total_bill_kes)            over (partition by department_id) as dept_revenue,
       round(avg(total_bill_kes)  over (partition by department_id), 0) as dept_avg_bill,
       count(*)                       over (partition by department_id) as visit_count,
       min(total_bill_kes)            over (partition by department_id) as lowest_bill,
       max(total_bill_kes)            over (partition by department_id) as highest_bill
from visits
order by department_id;
Enter fullscreen mode Exit fullscreen mode
department_id dept_revenue dept_avg_bill visit_count lowest_bill highest_bill
10 (Cardiology) 210100 23344 9 3500 82000
20 (General Medicine) 97800 10867 9 2000 36000
30 (Orthopedics) 105200 17533 6 6500 33000
40 (Pediatrics) 74900 12483 6 1800 42000

Without DISTINCT, every one of the 30 visit rows would carry its department's figures next to its own bill, which is perfect for questions like 'how does this bill compare with its department average?'

Running totals and moving averages use ORDER BY and a frame. Here we total each day's revenue first, then accumulate:

with daily as (
    select visit_date, sum(total_bill_kes) as revenue
    from visits
    group by visit_date
)
select visit_date, revenue,
       sum(revenue) over (order by visit_date) as running_total,
       round(avg(revenue) over (order by visit_date
             rows between 2 preceding and current row), 0) as moving_avg_3day
from daily
order by visit_date;
Enter fullscreen mode Exit fullscreen mode

First five of the 15 rows:

visit_date revenue running_total moving_avg_3day
2026-06-01 6000 6000 6000
2026-06-02 70000 76000 38000
2026-06-03 7000 83000 27667
2026-06-04 12000 95000 29667
2026-06-05 91000 186000 36667

The moving average on day 2 is (6000 + 70000) / 2 because only two rows exist so far; from day 3 on it averages the current day and the two before.

Window frames in depth: ROWS vs RANGE

The frame clause has this shape:

{ ROWS | RANGE } BETWEEN <start> AND <end>
-- start/end can be: UNBOUNDED PRECEDING, n PRECEDING, CURRENT ROW, n FOLLOWING, UNBOUNDED FOLLOWING
Enter fullscreen mode Exit fullscreen mode
  • ROWS counts physical rows.
  • RANGE works on values: all rows with the same ORDER BY value (peers) are treated as one unit.
  • The default, when ORDER BY is present, is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW.

The hospital data has two visits on most days, which shows the difference clearly:

select visit_id, visit_date, total_bill_kes,
       sum(total_bill_kes) over (order by visit_date) as range_default,
       sum(total_bill_kes) over (order by visit_date, visit_id
             rows between unbounded preceding and current row) as rows_running
from visits
where visit_date <= '2026-06-02'
order by visit_date, visit_id;
Enter fullscreen mode Exit fullscreen mode
visit_id visit_date total_bill_kes range_default rows_running
1001 2026-06-01 3500 6000 3500
1002 2026-06-01 2500 6000 6000
1003 2026-06-02 28000 76000 34000
1004 2026-06-02 42000 76000 76000

With RANGE, both visits on 1 June are peers, so each row already includes the other and shows 6000. With ROWS, the total grows one row at a time. Use ROWS for true row-by-row running totals; use RANGE when rows with the same value should be treated together.


Part 2: SQL Joins

A join combines rows from two tables using a condition, usually a foreign key matching a primary key. In our database visits.patient_id points to patients.patient_id, visits.doctor_id to doctors.doctor_id, and so on.

Join Returns
INNER JOIN only rows that match in both tables
LEFT JOIN all rows from the left table, plus matches from the right (NULL if none)
RIGHT JOIN all rows from the right table, plus matches from the left (NULL if none)
FULL OUTER JOIN all rows from both tables, matched where possible, NULL elsewhere
CROSS JOIN every combination of rows (the Cartesian product)
SELF JOIN a table joined to itself (not a separate keyword; uses aliases)

INNER JOIN

Use when you only want records that exist on both sides, such as visits together with their patient and doctor details.

Scenario: list every inpatient visit with the patient's name and the doctor who handled it.

select v.visit_id, p.patient_name, d.doctor_name, v.total_bill_kes
from visits v
inner join patients p on v.patient_id = p.patient_id
inner join doctors  d on v.doctor_id  = d.doctor_id
where v.admission_type = 'Inpatient'
order by v.visit_id;
Enter fullscreen mode Exit fullscreen mode
visit_id patient_name doctor_name total_bill_kes
1003 Grace Naliaka Dr. Peter Otieno 28000
1004 David Mutua Dr. Faith Chebet 42000
1010 Peter Ouma DR.Brian Mwangi 76000
1015 Eunice Moraa Dr. Peter Otieno 24000
1016 Francis Njuguna Dr. Sarah Wanjiku 36000
1017 Hellen Jepchirchir DR.Brian Mwangi 82000
1024 Nadia Hussein Dr. Faith Chebet 18500
1026 Patrick Muli Dr. Sarah Wanjiku 29000
1027 Ruth Nekesa Dr. Peter Otieno 33000

Nine inpatient visits, each now readable instead of just IDs. Notice that joins can be chained: two joins here, three tables combined.

LEFT JOIN

Use when you need every row from the main table even when there is no match: 'all patients, and their inpatient stay if they had one.'

In this dataset every patient has at least one visit, so a plain left join from patients to visits would show no NULLs. To demonstrate the behaviour realistically, we put the inpatient condition inside the ON clause:

select count(*) as total_rows, count(v.visit_id) as matched
from patients p
left join visits v
       on p.patient_id = v.patient_id
      and v.admission_type = 'Inpatient';
Enter fullscreen mode Exit fullscreen mode
total_rows matched
25 9

All 25 patients survive; only 9 have a matching inpatient visit, and the other 16 get NULL in the visit columns. (COUNT(v.visit_id) ignores NULLs, which is why it counts only real matches.)

The anti-join pattern: add WHERE v.visit_id IS NULL to keep only the patients with no match, meaning patients who have never been admitted:

select p.patient_id, p.patient_name
from patients p
left join visits v
       on p.patient_id = v.patient_id
      and v.admission_type = 'Inpatient'
where v.visit_id is null;
Enter fullscreen mode Exit fullscreen mode

This returns 16 patients, for example Amina Otieno (1), John Kariuki (2) and Esther Achieng (5).

Important: putting the filter in WHERE instead of ON would remove the NULL rows and quietly turn the left join into an inner join. Filters on the right table belong in ON.

RIGHT JOIN

A right join is a left join with the tables swapped: all rows of the right table are kept. Most people just write a left join and reorder the tables, but it is useful to know.

Scenario: list every diagnosis in the hospital's catalogue with the number of emergency visits it had, including diagnoses with none.

select d.diagnosis_name, count(v.visit_id) as emergency_visits
from visits v
right join diagnosis d
        on v.diagnosis_id = d.diagnosis_id
       and v.admission_type = 'Emergency'
group by d.diagnosis_id, d.diagnosis_name
order by emergency_visits desc, d.diagnosis_id;
Enter fullscreen mode Exit fullscreen mode

First rows of the 18 returned:

diagnosis_name emergency_visits
Chest Pain 1
Hypertension 0
Malaria 0
Fracture 0

Only one visit in the data was an emergency (visit 1030, chest pain), but the right join keeps all 18 diagnoses, with a count of zero where nothing matched. Writing from diagnosis d left join visits v ... would give the identical result.

FULL OUTER JOIN

Use when you need everything from both sides and want to see which rows match and which do not: reconciling two lists.

Scenario: compare patients who paid cash at least once with patients who have been admitted as inpatients.

with cash as (
    select distinct patient_id from visits where payment_method = 'Cash'
),
inpt as (
    select distinct patient_id from visits where admission_type = 'Inpatient'
)
select coalesce(c.patient_id, i.patient_id) as patient_id,
       case when c.patient_id is not null and i.patient_id is not null then 'both'
            when c.patient_id is not null then 'cash only'
            else 'inpatient only' end as segment
from cash c
full outer join inpt i on c.patient_id = i.patient_id
order by patient_id;
Enter fullscreen mode Exit fullscreen mode

The query returns 17 patients: 8 are cash only, 7 are inpatient only and 2 are in both lists (patients 4, David Mutua, and 22, Nadia Hussein). A full join is the only join that can show 'in A but not B', 'in B but not A' and 'in both' in one pass. COALESCE picks whichever side has the ID.

CROSS JOIN

A cross join pairs every row of one table with every row of the other: 4 doctors x 3 admission types = 12 rows. It has no ON clause.

Use when you need to generate all combinations, typically to build a complete grid so that combinations with zero activity still appear in a report.

select d.doctor_name, t.admission_type, count(v.visit_id) as visits
from doctors d
cross join (select 'Outpatient' as admission_type
            union all select 'Inpatient'
            union all select 'Emergency') t
left join visits v
       on v.doctor_id = d.doctor_id
      and v.admission_type = t.admission_type
group by d.doctor_id, d.doctor_name, t.admission_type
order by d.doctor_id, t.admission_type;
Enter fullscreen mode Exit fullscreen mode

First six of the 12 rows:

doctor_name admission_type visits
DR.Brian Mwangi Emergency 1
DR.Brian Mwangi Inpatient 2
DR.Brian Mwangi Outpatient 6
Dr. Sarah Wanjiku Emergency 0
Dr. Sarah Wanjiku Inpatient 2
Dr. Sarah Wanjiku Outpatient 7

The zero rows are the point: a normal GROUP BY would silently drop 'Dr. Sarah Wanjiku, Emergency' because no such visit exists. Caution: a cross join of two big tables explodes (1,000 x 1,000 = 1,000,000 rows), so use it deliberately.

SELF JOIN

A self join joins a table to itself, using two aliases so SQL can tell the copies apart. It suits comparisons between rows of the same table.

Scenario A: find pairs of patients from the same county (to plan a shared outreach clinic).

select a.patient_name as patient_1, b.patient_name as patient_2, a.county
from patients a
join patients b
  on a.county = b.county
 and a.patient_id < b.patient_id;
Enter fullscreen mode Exit fullscreen mode
patient_1 patient_2 county
Esther Achieng Brian Omondi Kisumu
Amina Otieno Isaac Kamau Nairobi

The condition a.patient_id < b.patient_id stops a patient being paired with themselves and prevents the same pair appearing twice in reverse order.

Scenario B: measure how many days passed between a patient's visits (re-visit analysis).

select v1.patient_id, v1.visit_id as first_visit, v2.visit_id as return_visit,
       v2.visit_date - v1.visit_date as days_between
from visits v1
join visits v2
  on v1.patient_id = v2.patient_id
 and v2.visit_date > v1.visit_date
order by v1.patient_id;
Enter fullscreen mode Exit fullscreen mode
patient_id first_visit return_visit days_between
1 1001 1011 5
2 1002 1021 10
5 1005 1025 10
7 1007 1028 10
10 1010 1030 10

The same answer is possible with LAG(), which is usually cleaner and faster. A self join is still the right tool when you need to compare every pair of rows, not just neighbours.

Choosing the right join

  • Need only matched records? INNER.
  • Need all of the main table, matched or not? LEFT (and swap the tables instead of using RIGHT).
  • Need to find rows with no match? LEFT + IS NULL.
  • Reconciling two sources? FULL OUTER.
  • Need every combination or a complete reporting grid? CROSS.
  • Comparing rows inside one table? SELF.

Part 3: Common Table Expressions (CTEs)

A CTE is a named temporary result defined with WITH, which exists only for the duration of one query. Think of it as giving a sub-step a name so the main query reads top to bottom like a story.

with name as (
    select ...
)
select ... from name;
Enter fullscreen mode Exit fullscreen mode

Basic CTE

Scenario: which departments earn more than the average department?

with dept_revenue as (
    select department_id, sum(total_bill_kes) as total_revenue
    from visits
    group by department_id
)
select d.department_name, r.total_revenue
from dept_revenue r
join department d on d.department_id = r.department_id
where r.total_revenue > (select avg(total_revenue) from dept_revenue)
order by r.total_revenue desc;
Enter fullscreen mode Exit fullscreen mode
department_name total_revenue
Cardiology 210100

The four departments bring in KES 488,000 in total, an average of KES 122,000 each, and only Cardiology is above that. The CTE is used twice: once to join names and once inside the average. That is the first advantage of CTEs: define once, reuse many times.

Multiple CTEs

Separate several CTEs with commas; later ones can use earlier ones.

Scenario: what percentage of total hospital revenue does each department bring in?

with dept_revenue as (
    select department_id, sum(total_bill_kes) as total_revenue
    from visits
    group by department_id
),
overall as (
    select sum(total_revenue) as grand_total
    from dept_revenue
)
select d.department_name, r.total_revenue,
       round(100.0 * r.total_revenue / o.grand_total, 1) as pct_of_total
from dept_revenue r
cross join overall o
join department d on d.department_id = r.department_id
order by r.total_revenue desc;
Enter fullscreen mode Exit fullscreen mode
department_name total_revenue pct_of_total
Cardiology 210100 43.1
Orthopedics 105200 21.6
General Medicine 97800 20.0
Pediatrics 74900 15.3

The second CTE (overall) is built from the first (dept_revenue). Cardiology alone brings in 43.1% of revenue. Each step is named and simple, where one giant query would be hard to read.

CTEs with joins and aggregations

Scenario: a doctor scorecard with visit count, revenue and average patient satisfaction. Aggregate first in a CTE, then join to the descriptive table.

with doctor_stats as (
    select doctor_id,
           count(*)                                   as visits,
           sum(total_bill_kes)                        as revenue,
           round(avg(patient_satisfaction_score), 2)  as avg_satisfaction
    from visits
    group by doctor_id
)
select d.doctor_name, d.doctor_specialty, s.visits, s.revenue, s.avg_satisfaction
from doctor_stats s
join doctors d on d.doctor_id = s.doctor_id
order by s.revenue desc;
Enter fullscreen mode Exit fullscreen mode
doctor_name doctor_specialty visits revenue avg_satisfaction
DR.Brian Mwangi Cardiologist 9 210100 4.49
Dr. Peter Otieno Orthopedic Surgeon 6 105200 4.23
Dr. Sarah Wanjiku General Physician 9 97800 4.00
Dr. Faith Chebet Pediatrician 6 74900 4.43

Aggregating before joining keeps the join small and avoids the classic mistake of aggregating after a join that multiplied rows.

Recursive CTEs

A recursive CTE refers to itself. It has two parts joined by UNION ALL: an anchor (the starting row) and a recursive member that builds on the previous output, repeating until a stopping condition is met. Typical uses are org charts, category trees and, as here, generating a series of dates.

Scenario: the visits data covers 1 to 15 June. Which days between 1 and 20 June have no recorded visits? A normal query cannot list days that do not exist in the table, so we generate the calendar first.

with recursive days as (
    select date '2026-06-01' as d            -- anchor
    union all
    select d + 1 from days                   -- recursive member
    where d < date '2026-06-20'              -- stop condition
)
select days.d, count(v.visit_id) as visits
from days
left join visits v on v.visit_date = days.d
group by days.d
having count(v.visit_id) = 0
order by days.d;
Enter fullscreen mode Exit fullscreen mode
d visits
2026-06-16 0
2026-06-17 0
2026-06-18 0
2026-06-19 0
2026-06-20 0

The CTE produces 20 consecutive dates, the LEFT JOIN attaches visits, and HAVING keeps the empty days. Always include a stop condition, otherwise the recursion never ends. (If the hospital stored staff reporting lines, say staff_id and manager_id, the same pattern would walk a whole chain of command.)

CTEs vs subqueries

Any CTE can be rewritten as a subquery. The same question both ways, 'visits with a bill above the average':

-- Subquery
select visit_id, total_bill_kes
from visits
where total_bill_kes > (select avg(total_bill_kes) from visits);

-- CTE
with avg_bill as (select avg(total_bill_kes) as avg_amount from visits)
select v.visit_id, v.total_bill_kes
from visits v, avg_bill a
where v.total_bill_kes > a.avg_amount;
Enter fullscreen mode Exit fullscreen mode
CTE Subquery
Readability reads top-down, steps have names nested inside the query, harder as depth grows
Reuse can be referenced several times in the same query must be repeated each time it is needed
Recursion supported not possible
Scope only the one statement that follows only where it is written
Best for multi-step logic, reuse, hierarchies short, one-off filters or single values

In PostgreSQL 12 and later, a CTE that is referenced once is normally inlined, so performance is usually the same as the equivalent subquery. You can force the old behaviour with AS MATERIALIZED. Rule of thumb: use a subquery for a small one-off filter, and a CTE once the logic has more than one step or you need the result twice.


Part 4: SQL Subqueries

A subquery is a SELECT inside another query, wrapped in parentheses. The inner query supplies a value, a list or a table to the outer query.

Where a subquery can sit

Clause What the subquery returns Example use
WHERE a value or a list used for filtering patients who had an inpatient stay
SELECT one value per output row (scalar) visit count next to each department
FROM a table, called a derived table (needs an alias) average stay per doctor, then joined

Scalar subquery

Returns exactly one row and one column, so it can be used like a value.

Scenario: find visits that cost more than the average visit.

select visit_id, total_bill_kes
from visits
where total_bill_kes > (select avg(total_bill_kes) from visits)
order by total_bill_kes desc;
Enter fullscreen mode Exit fullscreen mode

The inner query returns one number, KES 16,266.67. Ten visits are above it:

visit_id total_bill_kes
1017 82000
1010 76000
1004 42000
1016 36000
1027 33000
1026 29000
1003 28000
1015 24000
1030 22000
1024 18500

If a scalar subquery returns more than one row, PostgreSQL raises an error.

Non-correlated subquery

The inner query is independent of the outer query: it runs once, and its result is handed to the outer query. The scalar example above is non-correlated, and so is the IN example below.

Correlated subquery

The inner query refers to a column of the outer query, so it is re-evaluated for every outer row.

Scenario: find visits that cost more than the average for their own department, a fairer comparison than the global average.

select v.visit_id, v.department_id, v.total_bill_kes
from visits v
where v.total_bill_kes > (
    select avg(v2.total_bill_kes)
    from visits v2
    where v2.department_id = v.department_id   -- link to the outer row
)
order by v.department_id, v.visit_id;
Enter fullscreen mode Exit fullscreen mode
visit_id department_id total_bill_kes
1010 10 76000
1017 10 82000
1009 20 15000
1016 20 36000
1026 20 29000
1003 30 28000
1015 30 24000
1027 30 33000
1004 40 42000
1024 40 18500

Visit 1009 (KES 15,000) is below the hospital average of 16,267, so it was missing from the previous list, but it is well above its department's average of about 10,867, so it appears here. That is the value of correlation. The same result can be achieved with AVG() OVER (PARTITION BY department_id), which is often faster on large tables.

IN

IN tests whether a value appears in the list the subquery returns.

Scenario: names of patients who have been admitted as inpatients.

select patient_name
from patients
where patient_id in (
    select patient_id from visits where admission_type = 'Inpatient'
)
order by patient_id;
Enter fullscreen mode Exit fullscreen mode

Returns 9 patients: Grace Naliaka, David Mutua, Peter Ouma, Eunice Moraa, Francis Njuguna, Hellen Jepchirchir, Nadia Hussein, Patrick Muli and Ruth Nekesa.

NOT IN finds the opposite, but beware: if the subquery returns even one NULL, NOT IN returns no rows at all. Prefer NOT EXISTS when the column can be NULL. In visits, columns such as ward_id, diagnosis_id and procedure_id are nullable.

EXISTS

EXISTS returns true if the subquery returns at least one row. It is normally written as a correlated subquery, and it stops searching at the first match.

Scenario: patients who have ever been triaged as Red (the most urgent level).

select p.patient_name
from patients p
where exists (
    select 1
    from visits v
    where v.patient_id = p.patient_id
      and v.triage_level = 'Red'
)
order by p.patient_id;
Enter fullscreen mode Exit fullscreen mode

Returns 6 patients: Grace Naliaka, David Mutua, Peter Ouma, Eunice Moraa, Hellen Jepchirchir and Ruth Nekesa. The select 1 is a convention: EXISTS only cares whether a row exists, not what it contains.

IN vs EXISTS: both answer 'is there a match?'. IN compares against a list; EXISTS checks for existence and is safer with NULLs (especially as NOT EXISTS). On modern PostgreSQL the planner often treats them the same for simple cases.

ANY and ALL

These compare one value against every value the subquery returns.

  • > ANY (subquery): greater than at least one returned value (so, greater than the minimum).
  • > ALL (subquery): greater than every returned value (so, greater than the maximum).
  • = ANY is equivalent to IN.

Scenario: compare Pediatrics visits (department 40) with General Medicine visits (department 20).

-- Pediatrics bills higher than AT LEAST ONE General Medicine bill
select visit_id, total_bill_kes
from visits
where department_id = 40
  and total_bill_kes > any (select total_bill_kes from visits where department_id = 20);

-- Pediatrics bills higher than EVERY General Medicine bill
select visit_id, total_bill_kes
from visits
where department_id = 40
  and total_bill_kes > all (select total_bill_kes from visits where department_id = 20);
Enter fullscreen mode Exit fullscreen mode

General Medicine bills range from KES 2,000 to 36,000. So:

  • ANY keeps Pediatrics bills above 2,000: visits 1004 (42,000), 1007 (5,500), 1019 (5,200) and 1024 (18,500).
  • ALL keeps only bills above 36,000: just visit 1004 (42,000).

Nested subqueries

Subqueries can sit inside other subqueries. Read them from the inside out.

Scenario: which patients were treated by the more experienced doctors, meaning those with more years of experience than the average doctor?

select p.patient_name
from patients p
where p.patient_id in (
    select patient_id
    from visits
    where doctor_id in (
        select doctor_id
        from doctors
        where doctor_experience_years > (select avg(doctor_experience_years) from doctors)
    )
)
order by p.patient_id;
Enter fullscreen mode Exit fullscreen mode

Step by step: the innermost query returns the average experience (11 years); the next level returns the doctors above it (Dr. Brian Mwangi with 12 and Dr. Peter Otieno with 15); the next returns the patients who visited either of them; and the outer query turns those IDs into 12 names (Amina Otieno, John Kariuki, Grace Naliaka, Esther Achieng, Samuel Kiptoo, Peter Ouma, Catherine Musyoka, Eunice Moraa, Hellen Jepchirchir, Kevin Barasa, Moses Cheruiyot and Ruth Nekesa). Three levels works, but the same logic written as CTEs or joins would be easier to read, which is why deep nesting is usually a sign to switch.

Subquery in SELECT

A scalar subquery in the SELECT list adds a computed column to every row.

select d.department_name,
       (select count(*) from visits v where v.department_id = d.department_id) as visit_count,
       (select max(total_bill_kes) from visits v where v.department_id = d.department_id) as highest_bill
from department d
order by d.department_id;
Enter fullscreen mode Exit fullscreen mode
department_name visit_count highest_bill
Cardiology 9 82000
General Medicine 9 36000
Orthopedics 6 33000
Pediatrics 6 42000

Each subquery is correlated and runs once per department, which is fine for four rows but would be slow for large outer tables; a join with GROUP BY scales better.

Subquery in FROM (derived table)

A subquery in FROM acts as a temporary table. It must have an alias.

Scenario: average length of stay per doctor, joined back to the doctor names.

select d.doctor_name, t.avg_stay_days
from (
    select doctor_id, round(avg(length_of_stay_days), 2) as avg_stay_days
    from visits
    group by doctor_id
) t
join doctors d on d.doctor_id = t.doctor_id
order by t.avg_stay_days desc;
Enter fullscreen mode Exit fullscreen mode
doctor_name avg_stay_days
DR.Brian Mwangi 1.33
Dr. Peter Otieno 1.00
Dr. Faith Chebet 1.00
Dr. Sarah Wanjiku 0.56

The average includes outpatient visits (which have a stay of 0 days), so the numbers reflect the doctor's whole workload, not just admissions. Dr. Mwangi's cardiac cases keep patients longest.


Putting it all together

The four tools are not competitors; they are used together. A single report can use a CTE to aggregate, joins to attach names, a window function to rank and a subquery to filter. For example: 'for each department, show the top-billing visit with its patient and doctor':

with ranked as (
    select v.visit_id, v.department_id, v.patient_id, v.doctor_id, v.total_bill_kes,
           row_number() over (partition by v.department_id order by v.total_bill_kes desc) as rn
    from visits v
)
select dep.department_name, p.patient_name, d.doctor_name, r.total_bill_kes
from ranked r
join department dep on dep.department_id = r.department_id
join patients   p   on p.patient_id      = r.patient_id
join doctors    d   on d.doctor_id       = r.doctor_id
where r.rn = 1
order by r.total_bill_kes desc;
Enter fullscreen mode Exit fullscreen mode

This gives one row per department: Cardiology's top bill is KES 82,000 (Hellen Jepchirchir, Dr. Brian Mwangi), Pediatrics' is 42,000 (David Mutua, Dr. Faith Chebet), General Medicine's is 36,000 (Francis Njuguna, Dr. Sarah Wanjiku) and Orthopedics' is 33,000 (Ruth Nekesa, Dr. Peter Otieno).

Quick reference

Need Tool
Rank rows, with or without gaps RANK, DENSE_RANK, ROW_NUMBER
Compare with previous or next row LAG, LEAD
Running total or moving average SUM/AVG with OVER (ORDER BY ... ROWS ...)
Combine tables INNER, LEFT, FULL, CROSS, self joins
Break a problem into named steps CTE
Walk a hierarchy or generate a series recursive CTE
Filter by a value or list from another query subquery with IN, EXISTS, ANY, ALL

Conclusion

Window functions let you analyse rows in context without losing detail; joins connect the dimension tables to the visits fact table; CTEs make multi-step logic readable; and subqueries give you precise, flexible filters. On the hospital dataset these tools answered real questions: which patients stayed longest, which departments carry the revenue (Cardiology, with 43.1%), which returning patients' bills rose or fell, and who has never been admitted. The best way to build the skill is to write each query yourself, change a condition and watch how the result changes. The goal is not to memorise syntax but to recognise the shape of a question and pick the right tool.

Top comments (0)