Key takeaways
- Choose the join type from the question, not from habit.
- Check the key and the expected number of matches before joining.
- A left join filtered in the WHERE clause behaves like an inner join.
- Compare row counts and totals after every join.
On this page
Start with the grain and the question
A join combines rows from two tables using a shared value. Before choosing a join type, write down what one row of the result should represent. One row per order, one row per customer, or one row per order line? That statement is the grain, and it decides which table must stay intact.
A question such as total revenue by customer needs one row per customer. A question about late deliveries needs one row per order. The same tables can support both, but the joins and aggregation differ. Deciding the grain first prevents the habit of joining everything and fixing totals afterwards.
Inner joins keep only matches
An inner join returns rows that have a match on both sides. Customers without orders disappear, and orders without a valid customer disappear. That is correct when the report concerns matched pairs, such as revenue for products in the catalogue.
It is wrong when missing rows carry meaning. A customer with no orders disappears from a coverage report, and the total looks fine because nothing is flagged. Ask what the unmatched rows represent before accepting an inner join.
Left joins keep the left table intact
A left join keeps every row from the left table and fills columns from the right table with nulls when there is no match. Put the table you cannot afford to lose on the left: for a customer list with order activity, that is customers.
The most common mistake is adding a condition on the right table to the WHERE clause. That condition removes the null rows and turns the left join into an inner join. Put the condition in the ON clause instead if the intent is to keep unmatched rows.
- ON controls which right-hand rows match; WHERE filters the result after the join.
- A left join can multiply rows when the right table has several matches per key.
- Test with a customer who has no orders; the row should still appear.
Check the key and the cardinality
The join key must have the same meaning and type on both sides. Joining a customer code to a similar-looking customer number will produce either no matches or false matches. Check leading zeros, mixed case, and padding before blaming the join.
Cardinality describes how many rows sit on each side. A customer table should have one row per customer; an orders table may have many. Joining one to many is normal; many to many multiplies both sides and inflates sums.
| Relationship | Example | Effect on rows | Check |
|---|---|---|---|
| One to one | Customer to customer profile | Row count stays the same | Both keys are unique |
| One to many | Customer to orders | One row per matching order | Right key is not unique |
| Many to one | Orders to customer | Row count stays the same | Left key may repeat |
| Many to many | Orders to promotions | Rows multiply on both sides | Pre-aggregate or use a bridge |
| No match | Order to missing product | Inner drops it; left keeps nulls | Count unmatched keys |
Pre-aggregate before joining detail
If the right table has several rows per key, aggregate it to the required grain first. A customer with three orders would otherwise repeat the customer row three times, and a later sum of a customer-level column would count it three times.
The query below aggregates orders to one row per customer for the current month, then joins that result to the customer list. The left join keeps customers with no orders, and COALESCE turns the missing values into zeros.
WITH order_totals AS (
SELECT
customer_id,
COUNT(*) AS orders,
SUM(order_total) AS revenue
FROM orders
WHERE order_date >= DATE_TRUNC('month', CURRENT_DATE)
AND order_date < DATE_TRUNC('month', CURRENT_DATE) + INTERVAL '1 month'
GROUP BY customer_id
)
SELECT
c.customer_id,
c.customer_name,
COALESCE(o.orders, 0) AS orders,
COALESCE(o.revenue, 0) AS revenue
FROM customers AS c
LEFT JOIN order_totals AS o
ON o.customer_id = c.customer_id
ORDER BY revenue DESC;Validate the join before you trust the total
Record the row count before and after the join, and count unmatched keys on each side. If the result has more rows than expected, inspect keys with multiple matches; if fewer, find which rows disappeared.
Reconcile a measure as well as the count. Sum the order total before and after the join. A stable row count with a changed total usually means a duplicated key or a misplaced filter.
- Count distinct keys on both sides before joining.
- Compare totals, not only row counts.
- Keep unmatched rows visible in a separate check.
Frequently asked questions
Is a left join always the right choice?+
No. Use an inner join when only matched rows belong in the result, and a left join when unmatched left-hand rows must remain visible. The question decides.
Why do totals double after adding a join?+
The join probably matched more than one row per key. Aggregate the right table to the correct grain first, or check whether the key is unique.
What is a many-to-many join?+
It is a relationship where both tables can have several rows per key. Joining them directly multiplies rows. Use a bridge table or pre-aggregate one side before joining.
Does join order affect the result?+
For inner joins the final rows are the same, though performance can differ. For outer joins, order and filter placement change which rows are kept.
Need help applying this?
Get help with Data Cleanup & Pipelines.
Review the service scope or tell us what you are trying to improve. We will reply with a clear next step.
Free KPI definition templateRelated reading
SQL window functions for running totals
How to calculate running totals with SUM OVER, choose the right window frame, reset by partition, and check ties and gaps.
SQL for business analysts: five query patterns worth knowing
Five practical SQL patterns for reporting: aggregation, joins, date filtering, window functions, and data quality checks.