SQL joins and window functions with results

Updated October 9, 2026

A join combines columns from two tables by matching rows, and a window function calculates across a set of related rows without collapsing them into one. The decision rule: use a join when you need columns from another table, use a window function when you need a per-group calculation (rank, running total, previous value) and still want every row back. GROUP BY collapses rows, a window function with OVER does not.

Every result below was produced by running the statements on a small data set (SQLite 3.46, using the standard SQL syntax that T-SQL, Spark SQL and Databricks SQL share for these cases). Run them yourself; reading a result table is faster than reading a definition.

The data used for every example

customers

idname
1Ada
2Ben
3Cleo

orders (order 13 has no customer)

order_idcustomer_idamountorder_date
1011002026-01-05
1112502026-02-10
122802026-01-20
13NULL402026-03-01

Join types, side by side

FROM customers c JOIN orders o ON o.customer_id = c.id with four variants:

JoinRows returnedWhat happened to Cleo and order 13
INNER JOINAda/10, Ada/11, Ben/12 (3 rows)Both dropped, no match on either side
LEFT JOINthe three above plus Cleo with NULL order (4 rows)Cleo kept, order 13 dropped
orders LEFT JOIN customersthe three above plus order 13 with NULL name (4 rows)Order 13 kept, Cleo dropped
FULL OUTER JOINthe three matches, Cleo with NULL order, and order 13 with NULL name (5 rows)Both kept

A right join is a left join with the tables swapped, so exams use it to test whether you can read which side is preserved. The preserved side is the one written next to the word LEFT or RIGHT.

Anti join finds rows with no match. Customers who never ordered:

SELECT c.name FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.order_id IS NULL;

Result: one row, Cleo. Spark and Databricks also offer LEFT ANTI JOIN and LEFT SEMI JOIN directly; a semi join returns left rows that have a match but no right-side columns and never duplicates them.

Where the filter goes changes the answer

For a left join, a condition on the right table in WHERE runs after the join and removes the NULL rows, which quietly turns it into an inner join. The same condition in ON filters only the matching:

QueryResult
LEFT JOIN ... WHERE o.amount > 90Ada/10, Ada/11 (Ben and Cleo vanish)
LEFT JOIN ... ON ... AND o.amount > 90Ada/10, Ada/11, Ben/NULL, Cleo/NULL

This is the most common reason a “customers with their large orders” report loses customers.

Row multiplication: the silent join bug

Joins multiply rows when the key is not unique on the other side. Give Ada two phone numbers in a ph table, join orders to ph on customer, and sum the amounts for Ada: the result is 700 instead of the true 350, because each of her two orders now appears twice. The symptom exams describe is “the total is higher after adding a join” or a fact table that grows from 50,000 to 73,000 rows. The fix is to aggregate first or join to a table that is unique on the key, which matters for any exam that asks you to join and then aggregate data, such as DP-600 and the Databricks exams. A star schema avoids this by design, see star schema and modelling.

Window functions: the OVER clause

A window function has three parts inside OVER (...): PARTITION BY (the groups, optional), ORDER BY (the sequence within each group) and optionally a frame. For customers 1 and 2:

SELECT order_id, customer_id, amount,
  ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC) AS rn,
  SUM(amount)  OVER (PARTITION BY customer_id ORDER BY order_date)      AS running,
  LAG(amount)  OVER (PARTITION BY customer_id ORDER BY order_date)      AS prev
FROM orders WHERE customer_id IS NOT NULL;
order_idcustomer_idamountrnrunningprev
1011002100NULL
1112501350100
12280180NULL

All three rows survive; a GROUP BY would have returned two. Note that running uses ORDER BY inside the window, which makes the sum cumulative. Without ORDER BY the sum covers the whole partition.

ROW_NUMBER, RANK and DENSE_RANK on ties

Scores A=90, B=90, C=80, D=70 ordered descending:

namescoreROW_NUMBERRANKDENSE_RANK
A90111
B90211
C80332
D70443

ROW_NUMBER never repeats, so on a tie it picks an arbitrary order unless you add a tiebreaker column. RANK repeats and then skips (1, 1, 3). DENSE_RANK repeats without skipping (1, 1, 2).

Latest row per group

The pattern most often tested: number the rows per group newest first, then keep number 1. Window functions cannot be used directly in WHERE, so wrap the query:

SELECT * FROM (
  SELECT o.*, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC) AS rn
  FROM orders o WHERE customer_id IS NOT NULL
) t WHERE rn = 1;

Result: order 11 for Ada and order 12 for Ben. Databricks SQL and some other engines also offer QUALIFY to filter on a window result without the wrapper, T-SQL does not.

Which exams test this

ExamHow it appearsOn this site
DP-800Design and develop database solutions (35–40%): write advanced T-SQL, naming queries with window functions, CTEs and correlated queries. Joins are not listed separately but underlie these objectivesDesign and develop
Databricks Data Analyst AssociateExecuting queries (20%): combine tables with inner, left, right and other joins on single or multiple keys, set operations such as UNION and UNION ALL, and joins across a Delta table and a federated source. Window functions are not named in the guideExecuting queries
Databricks Data Engineer ProfessionalData Manipulation (12%, outline from October 9, 2026): advanced transformations with window functions, joins and aggregations in Spark SQL and PySpark; query-profile analysis of join strategiesDeveloping code
Databricks Data Engineer AssociateData Transformation and Modeling (22%): combine DataFrames with inner, left, broadcast and cross joins, multiple keys, UNION and UNION ALL. Window functions are not namedDatabricks Data Engineer: transformation and modelling
Databricks Spark Developer AssociateDeveloping DataFrame/Dataset API applications (30%): inner, left, broadcast and cross joins, multiple keys, UNION and UNION ALL, and the purpose of broadcast joins. SQL window functions are not namedDataFrame API explained
DP-750Prepare and process data (30–35%): transform data with join, union, intersect and except operators. Window functions are not namedPrepare and process data
DP-600Prepare data (45–50%): merge or join data, and select, filter and aggregate data with SQL. Window functions are not namedPrepare data
DP-700Ingest and transform data (30–35%): transform data with PySpark, SQL and KQL, denormalize data, and handle duplicate data, where keeping the latest row per key uses row_number. An indirect topic: joins and SQL window functions are not named (the “windowing functions” objective refers to streaming windows)Ingest and transform

How questions are built around joins and windows

The scenario gives a symptom and asks for the cause or the query. “Customers with no orders” is an anti join. “Total is too high after the join” is row multiplication. “A left join returns the same rows as an inner join” is a right-table filter in WHERE. “Top one per group” or “most recent per customer” is ROW_NUMBER with a partition, and “ties share the same position” is RANK or DENSE_RANK, where “no gaps” decides between them. “Compare with the previous period” is LAG. A subquery per row is usually presented as the slow wrong option next to a join or window. Delta tables in a medallion pipeline use these same patterns to deduplicate in silver, and Delta Lake MERGE often takes the ROW_NUMBER result as its source.

Question 1. A table of support tickets has ticket_id, customer_id and created_at. A report must show exactly one row per customer: the customer's most recent ticket. Which approach is correct?

  • A. GROUP BY customer_id and select all columns
  • B. A self join on customer_id
  • C. RANK partitioned by customer_id ordered by created_at ascending, filtered to 1
  • D. ROW_NUMBER partitioned by customer_id ordered by created_at descending, filtered to 1
Show answer

Answer: D

Number the tickets per customer from newest to oldest with ROW_NUMBER partitioned by customer_id, then keep row number 1. GROUP BY customer_id cannot select the other columns that are not grouped or aggregated, and even with MAX(created_at) it returns the date but not the rest of that ticket without another join. RANK ordered ascending picks the oldest ticket instead of the newest, and RANK would also return several rows for tied timestamps. An inner self join on equal customer_id multiplies rows instead of reducing them.

Question 2. An analyst joins an orders table to a customer_contacts table, which holds several rows per customer, and then sums order amount. The total is higher than in the finance system. What is the most likely cause?

  • A. Rows were multiplied because customer_id is not unique in customer_contacts
  • B. The query should have used a LEFT JOIN instead of an INNER JOIN
  • C. A window function added extra rows
  • D. The query lacks an ORDER BY clause
Show answer

Answer: A

The join key is not unique in customer_contacts, so each order row is repeated once per contact and SUM counts it several times. Switching to a left join keeps the duplication. Window functions do not change row counts and are not involved. A missing ORDER BY does not change the value of a sum.

Go deeper

On this site: KQL cheat sheet for join kinds in the Kusto language, Power Query basics for merges, and the skills outline.