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:
- Window functions: calculations across related rows without collapsing them.
- Joins: combining tables.
- Common Table Expressions (CTEs): naming intermediate results.
- 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; 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) ) -
OVER ()with nothing inside means the window is the whole result set. -
PARTITION BYrestarts the calculation for each group, likeGROUP BYbut without collapsing rows. Partition bydoctor_idand each doctor gets their own ranking. -
ORDER BYinsideOVERdecides the order the function sees rows in. It matters for ranking,LAG/LEAD, and running totals. It is separate from theORDER BYat 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; 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; 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; | 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; | 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; 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; | 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; | 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; 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 -
ROWScounts physical rows. -
RANGEworks on values: all rows with the sameORDER BYvalue (peers) are treated as one unit. - The default, when
ORDER BYis present, isRANGE 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; | 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; | 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'; | 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; 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; 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; 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_typeSource:https://dev.to/adrian_mageto/sql-window-functions-joins-ctes-and-subqueries-a-practical-guide-using-a-hospital-dataset-3aheRelated posts
Postgres LISTEN/NOTIFY vs Redis Pub/Sub: Latency, Write Cost and Failure Cases, Tested
LlamaIndex Workflows vs LangGraph: Event-Driven Architecture and Async State Overhead Showdown
