Learn with DataTug · English

SQL JOINs vs Subqueries: When to Use Each (and Why)

Choose JOIN, EXISTS, IN or a scalar subquery by the rows you need. Compare real Chinook results, understand duplicates and NULL, and practise with tested SQLite examples.

By DataTug · AI-assisted writing · Published . SQL examples are checked against the downloadable Chinook SQLite database.

Find customers who have placed at least one order

A support engineer needs a list of paying customers. In Chinook, a completed purchase is represented by an invoice. The question sounds simple: “Which customers have at least one invoice?” But should the report contain one row per customer, or one row per invoice? That choice—the result grain—matters more than choosing a favourite SQL keyword.

A JOIN combines rows from two table expressions using a relationship. A subquery is a query nested inside another SQL statement. They are not opposing categories: a JOIN can join a subquery. Use a join when you need related columns, and consider EXISTS when you only need to know whether a related row exists.

The examples show why three queries identify the same customers while another returns 412 invoice matches for 59 customers. They also show why “joins are always faster” is not a useful rule. SQL semantics determine correctness; the engine’s plan and measurements determine performance.

Prerequisite: basic SELECT and WHERE; GROUP BY foundations helps with summaries. Related: Aggregation · Filtering · Windows. Practice: this lesson’s exercises. Follow the SQL Learning Hub path.

The Chinook sample database

These examples use the complete Chinook music-store database available in the DataTug demo: 59 customers and 412 invoices, with every original table and value retained. All 59 customers have invoices. Queries that focus on one customer or qualifying invoices show that condition explicitly in the SQL.

The relationship is Customer.CustomerId → Invoice.CustomerId: one customer can have many invoices; each invoice belongs to one customer. InvoiceId is the invoice’s unique key. InvoiceDate is ISO-sortable text; Total is a numeric amount in the dataset’s units. This source uses dates from 2021–2025; other Chinook releases differ. Invoice IDs are not necessarily chronological.

Results are shown as a preview for clarity. Tables with more than 10 records initially show the first 10. Choose Show all to reveal every matching record, or Run to execute the exact displayed SQL in the DataTug editor against the same Chinook database. No query is saved to the project. The preview does not add a WHERE or LIMIT clause. Synthetic examples define their own rows with VALUES in the displayed query.

Download the complete Chinook SQLite database and open it with SQLite 3.25.0 or later, then run any query below. No account or network database is required. Query downloads contain every displayed example and exercise solution. Frozen result tables are checked against this database; each code header states its minimum SQLite version.

Chinook upstream and schema · Chinook licence · Source attribution. SQLite uses binary floating-point for many decimal operations; examples round money for display. Production finance needs an explicit exact-decimal or integer-minor-unit policy.

Customer source rows

Who are the 59 customers?

SQLite 3+ · Chinook
SELECT CustomerId, FirstName, LastName, Country
FROM Customer ORDER BY CustomerId;
Show all 59 recordsShow first 10 records
Customer source rows: expected result · Showing 10 of 59 records for clarity59 records
CustomerIdFirstNameLastNameCountry
1LuísGonçalvesBrazil
2LeonieKöhlerGermany
3FrançoisTremblayCanada
4BjørnHansenNorway
5FrantišekWichterlováCzech Republic
6HelenaHolýCzech Republic
7AstridGruberAustria
8DaanPeetersBelgium
9KaraNielsenDenmark
10EduardoMartinsBrazil
11AlexandreRochaBrazil
12RobertoAlmeidaBrazil
13FernandaRamosBrazil
14MarkPhilipsCanada
15JenniferPetersonCanada
16FrankHarrisUSA
17JackSmithUSA
18MichelleBrooksUSA
19TimGoyerUSA
20DanMillerUSA
21KathyChaseUSA
22HeatherLeacockUSA
23JohnGordonUSA
24FrankRalstonUSA
25VictorStevensUSA
26RichardCunninghamUSA
27PatrickGrayUSA
28JuliaBarnettUSA
29RobertBrownCanada
30EdwardFrancisCanada
31MarthaSilkCanada
32AaronMitchellCanada
33EllieSullivanCanada
34JoãoFernandesPortugal
35MadalenaSampaioPortugal
36HannahSchneiderGermany
37FynnZimmermannGermany
38NiklasSchröderGermany
39CamilleBernardFrance
40DominiqueLefebvreFrance
41MarcDuboisFrance
42WyattGirardFrance
43IsabelleMercierFrance
44TerhiHämäläinenFinland
45LadislavKovácsHungary
46HughO'ReillyIreland
47LucasManciniItaly
48JohannesVan der BergNetherlands
49StanisławWójcikPoland
50EnriqueMuñozSpain
51JoakimJohanssonSweden
52EmmaJonesUnited Kingdom
53PhilHughesUnited Kingdom
54SteveMurrayUnited Kingdom
55MarkTaylorAustralia
56DiegoGutiérrezArgentina
57LuisRojasChile
58ManojPareekIndia
59PujaSrivastavaIndia

Invoice source rows

Which invoices belong to customer 1?

SQLite 3+ · Chinook
SELECT InvoiceId, CustomerId, InvoiceDate, Total
FROM Invoice WHERE CustomerId = 1
ORDER BY InvoiceDate, InvoiceId;
Invoice source rows: expected result · 7 records
InvoiceIdCustomerIdInvoiceDateTotal
9812022-03-11 00:00:003.98
12112022-06-13 00:00:003.96
14312022-09-15 00:00:005.94
19512023-05-06 00:00:000.99
31612024-10-27 00:00:001.98
32712024-12-07 00:00:0013.86
38212025-08-07 00:00:008.91

JOINs describe relationships, not deduplication

An INNER JOIN emits a row for each pair satisfying its condition. A customer with seven invoices participates in seven matching pairs. A customer with none participates in none. Selecting only the customer’s name does not change how many pairs exist.

One customer, many matching invoices

  1. 1Customer 1: Luís (one source row)
  2. 2Invoice.CustomerId = 1: seven source rows
  3. 3INNER JOIN: seven rows with CustomerId 1
The customer repeats once per matching invoice. These are distinct pairs, even when a customer-only projection looks duplicated.

INNER JOIN: every matching invoice

Which customers have invoices? Inspect every match.

SQLite 3+ · Chinook
SELECT c.CustomerId, c.FirstName, i.InvoiceId
FROM Customer AS c
INNER JOIN Invoice AS i ON i.CustomerId = c.CustomerId
ORDER BY c.CustomerId, i.InvoiceId;
Show all 412 recordsShow first 10 records
INNER JOIN: every matching invoice: expected result · Showing 10 of 412 records for clarity412 records
CustomerIdFirstNameInvoiceId
1Luís98
1Luís121
1Luís143
1Luís195
1Luís316
1Luís327
1Luís382
2Leonie1
2Leonie12
2Leonie67
2Leonie196
2Leonie219
2Leonie241
2Leonie293
3François99
3François110
3François165
3François294
3François317
3François339
3François391
4Bjørn2
4Bjørn24
4Bjørn76
4Bjørn197
4Bjørn208
4Bjørn263
4Bjørn392
5František77
5František100
5František122
5František174
5František295
5František306
5František361
6Helena46
6Helena175
6Helena198
6Helena220
6Helena272
6Helena393
6Helena404
7Astrid78
7Astrid89
7Astrid144
7Astrid273
7Astrid296
7Astrid318
7Astrid370
8Daan3
8Daan55
8Daan176
8Daan187
8Daan242
8Daan371
8Daan394
9Kara56
9Kara79
9Kara101
9Kara153
9Kara274
9Kara285
9Kara340
10Eduardo25
10Eduardo154
10Eduardo177
10Eduardo199
10Eduardo251
10Eduardo372
10Eduardo383
11Alexandre57
11Alexandre68
11Alexandre123
11Alexandre252
11Alexandre275
11Alexandre297
11Alexandre349
12Roberto34
12Roberto155
12Roberto166
12Roberto221
12Roberto350
12Roberto373
12Roberto395
13Fernanda35
13Fernanda58
13Fernanda80
13Fernanda132
13Fernanda253
13Fernanda264
13Fernanda319
14Mark4
14Mark133
14Mark156
14Mark178
14Mark230
14Mark351
14Mark362
15Jennifer36
15Jennifer47
15Jennifer102
15Jennifer231
15Jennifer254
15Jennifer276
15Jennifer328
16Frank13
16Frank134
16Frank145
16Frank200
16Frank329
16Frank352
16Frank374
17Jack14
17Jack37
17Jack59
17Jack111
17Jack232
17Jack243
17Jack298
18Michelle112
18Michelle135
18Michelle157
18Michelle209
18Michelle330
18Michelle341
18Michelle396
19Tim15
19Tim26
19Tim81
19Tim210
19Tim233
19Tim255
19Tim307
20Dan113
20Dan124
20Dan179
20Dan308
20Dan331
20Dan353
20Dan405
21Kathy16
21Kathy38
21Kathy90
21Kathy211
21Kathy222
21Kathy277
21Kathy406
22Heather91
22Heather114
22Heather136
22Heather188
22Heather309
22Heather320
22Heather375
23John5
23John60
23John189
23John212
23John234
23John286
23John407
24Frank92
24Frank103
24Frank158
24Frank287
24Frank310
24Frank332
24Frank384
25Victor17
25Victor69
25Victor190
25Victor201
25Victor256
25Victor385
25Victor408
26Richard70
26Richard93
26Richard115
26Richard167
26Richard288
26Richard299
26Richard354
27Patrick39
27Patrick168
27Patrick191
27Patrick213
27Patrick265
27Patrick386
27Patrick397
28Julia71
28Julia82
28Julia137
28Julia266
28Julia289
28Julia311
28Julia363
29Robert48
29Robert169
29Robert180
29Robert235
29Robert364
29Robert387
29Robert409
30Edward49
30Edward72
30Edward94
30Edward146
30Edward267
30Edward278
30Edward333
31Martha18
31Martha147
31Martha170
31Martha192
31Martha244
31Martha365
31Martha376
32Aaron50
32Aaron61
32Aaron116
32Aaron245
32Aaron268
32Aaron290
32Aaron342
33Ellie27
33Ellie148
33Ellie159
33Ellie214
33Ellie343
33Ellie366
33Ellie388
34João28
34João51
34João73
34João125
34João246
34João257
34João312
35Madalena126
35Madalena149
35Madalena171
35Madalena223
35Madalena344
35Madalena355
35Madalena410
36Hannah29
36Hannah40
36Hannah95
36Hannah224
36Hannah247
36Hannah269
36Hannah321
37Fynn6
37Fynn127
37Fynn138
37Fynn193
37Fynn322
37Fynn345
37Fynn367
38Niklas7
38Niklas30
38Niklas52
38Niklas104
38Niklas225
38Niklas236
38Niklas291
39Camille105
39Camille128
39Camille150
39Camille202
39Camille323
39Camille334
39Camille389
40Dominique8
40Dominique19
40Dominique74
40Dominique203
40Dominique226
40Dominique248
40Dominique300
41Marc106
41Marc117
41Marc172
41Marc301
41Marc324
41Marc346
41Marc398
42Wyatt9
42Wyatt31
42Wyatt83
42Wyatt204
42Wyatt215
42Wyatt270
42Wyatt399
43Isabelle84
43Isabelle107
43Isabelle129
43Isabelle181
43Isabelle302
43Isabelle313
43Isabelle368
44Terhi53
44Terhi182
44Terhi205
44Terhi227
44Terhi279
44Terhi400
44Terhi411
45Ladislav85
45Ladislav96
45Ladislav151
45Ladislav280
45Ladislav303
45Ladislav325
45Ladislav377
46Hugh10
46Hugh62
46Hugh183
46Hugh194
46Hugh249
46Hugh378
46Hugh401
47Lucas63
47Lucas86
47Lucas108
47Lucas160
47Lucas281
47Lucas292
47Lucas347
48Johannes32
48Johannes161
48Johannes184
48Johannes206
48Johannes258
48Johannes379
48Johannes390
49Stanisław64
49Stanisław75
49Stanisław130
49Stanisław259
49Stanisław282
49Stanisław304
49Stanisław356
50Enrique41
50Enrique162
50Enrique173
50Enrique228
50Enrique357
50Enrique380
50Enrique402
51Joakim42
51Joakim65
51Joakim87
51Joakim139
51Joakim260
51Joakim271
51Joakim326
52Emma11
52Emma140
52Emma163
52Emma185
52Emma237
52Emma358
52Emma369
53Phil43
53Phil54
53Phil109
53Phil238
53Phil261
53Phil283
53Phil335
54Steve20
54Steve141
54Steve152
54Steve207
54Steve336
54Steve359
54Steve381
55Mark21
55Mark44
55Mark66
55Mark118
55Mark239
55Mark250
55Mark305
56Diego119
56Diego142
56Diego164
56Diego216
56Diego337
56Diego348
56Diego403
57Luis22
57Luis33
57Luis88
57Luis217
57Luis240
57Luis262
57Luis314
58Manoj120
58Manoj131
58Manoj186
58Manoj315
58Manoj338
58Manoj360
58Manoj412
59Puja23
59Puja45
59Puja97
59Puja218
59Puja229
59Puja284

412 invoice matches across 59 customers. Most customers have seven invoices; one has six. Repeated customer identifiers are the correct one-to-many result, not duplicate invoices.

A LEFT JOIN also keeps a left-side row when no right-side match exists. Right-side columns become NULL. It does not promise one row per left record: multiple matches still multiply rows. Use it when unmatched records belong in the report.

LEFT JOIN: preserve unmatched customers

Who has an invoice over 20 units, while keeping every customer visible?

SQLite 3+ · Chinook
SELECT c.CustomerId, c.FirstName, i.InvoiceId, i.Total
FROM Customer AS c
LEFT JOIN Invoice AS i
 ON i.CustomerId = c.CustomerId AND i.Total > 20
ORDER BY c.CustomerId, i.InvoiceId;
Show all 59 recordsShow first 10 records
LEFT JOIN: preserve unmatched customers: expected result · Showing 10 of 59 records for clarity59 records
CustomerIdFirstNameInvoiceIdTotal
1LuísNULLNULL
2LeonieNULLNULL
3FrançoisNULLNULL
4BjørnNULLNULL
5FrantišekNULLNULL
6Helena40425.86
7AstridNULLNULL
8DaanNULLNULL
9KaraNULLNULL
10EduardoNULLNULL
11AlexandreNULLNULL
12RobertoNULLNULL
13FernandaNULLNULL
14MarkNULLNULL
15JenniferNULLNULL
16FrankNULLNULL
17JackNULLNULL
18MichelleNULLNULL
19TimNULLNULL
20DanNULLNULL
21KathyNULLNULL
22HeatherNULLNULL
23JohnNULLNULL
24FrankNULLNULL
25VictorNULLNULL
26Richard29923.86
27PatrickNULLNULL
28JuliaNULLNULL
29RobertNULLNULL
30EdwardNULLNULL
31MarthaNULLNULL
32AaronNULLNULL
33EllieNULLNULL
34JoãoNULLNULL
35MadalenaNULLNULL
36HannahNULLNULL
37FynnNULLNULL
38NiklasNULLNULL
39CamilleNULLNULL
40DominiqueNULLNULL
41MarcNULLNULL
42WyattNULLNULL
43IsabelleNULLNULL
44TerhiNULLNULL
45Ladislav9621.86
46Hugh19421.86
47LucasNULLNULL
48JohannesNULLNULL
49StanisławNULLNULL
50EnriqueNULLNULL
51JoakimNULLNULL
52EmmaNULLNULL
53PhilNULLNULL
54SteveNULLNULL
55MarkNULLNULL
56DiegoNULLNULL
57LuisNULLNULL
58ManojNULLNULL
59PujaNULLNULL

Only customers 6, 26, 45 and 46 have an invoice over 20. The other 55 customers receive a null-extended row. Keeping the amount condition in ON preserves them; moving it to WHERE removes them.

Conditions in ON decide which invoice can match. A condition on i.Total in the outer WHERE runs after null extension; unknown comparisons are discarded. Moving that condition can remove customers without qualifying invoices. That is a correctness change, not a formatting preference.

A subquery can return a value, a set, or a table

A scalar subquery supplies one value, usable in a selected expression or comparison. A non-correlated subquery does not refer to outer-query columns. A correlated subquery does: its meaning depends on the current outer row. These describe dependencies, not a guarantee that the engine physically reruns a query for every row.

Correlated EXISTS: a per-customer question

  1. 1Read the outer customer identifier
  2. 2Ask whether any invoice has that CustomerId
  3. 3Keep the customer once if yes
This is logical evaluation. The optimiser may use an indexed probe, a semi-join strategy or another equivalent plan.

EXISTS: test for a match

Which customers have at least one invoice?

SQLite 3+ · Chinook
SELECT c.CustomerId, c.FirstName
FROM Customer AS c
WHERE EXISTS (
 SELECT 1 FROM Invoice AS i
 WHERE i.CustomerId = c.CustomerId
)
ORDER BY c.CustomerId;
Show all 59 recordsShow first 10 records
EXISTS: test for a match: expected result · Showing 10 of 59 records for clarity59 records
CustomerIdFirstName
1Luís
2Leonie
3François
4Bjørn
5František
6Helena
7Astrid
8Daan
9Kara
10Eduardo
11Alexandre
12Roberto
13Fernanda
14Mark
15Jennifer
16Frank
17Jack
18Michelle
19Tim
20Dan
21Kathy
22Heather
23John
24Frank
25Victor
26Richard
27Patrick
28Julia
29Robert
30Edward
31Martha
32Aaron
33Ellie
34João
35Madalena
36Hannah
37Fynn
38Niklas
39Camille
40Dominique
41Marc
42Wyatt
43Isabelle
44Terhi
45Ladislav
46Hugh
47Lucas
48Johannes
49Stanisław
50Enrique
51Joakim
52Emma
53Phil
54Steve
55Mark
56Diego
57Luis
58Manoj
59Puja

The correlation links invoices to the current outer customer. The value 1 is irrelevant: EXISTS tests the presence of a row.

EXISTS ignores selected values: even a selected NULL establishes existence if a row exists. The correlation predicate does the essential work. IN tests whether an expression belongs to a single-column subquery result. Both are useful in WHERE; choose the one making the relationship clearest.

IN: membership

Which customer IDs occur in Invoice?

SQLite 3+ · Chinook
SELECT CustomerId, FirstName
FROM Customer
WHERE CustomerId IN (SELECT CustomerId FROM Invoice)
ORDER BY CustomerId;
Show all 59 recordsShow first 10 records
IN: membership: expected result · Showing 10 of 59 records for clarity59 records
CustomerIdFirstName
1Luís
2Leonie
3François
4Bjørn
5František
6Helena
7Astrid
8Daan
9Kara
10Eduardo
11Alexandre
12Roberto
13Fernanda
14Mark
15Jennifer
16Frank
17Jack
18Michelle
19Tim
20Dan
21Kathy
22Heather
23John
24Frank
25Victor
26Richard
27Patrick
28Julia
29Robert
30Edward
31Martha
32Aaron
33Ellie
34João
35Madalena
36Hannah
37Fynn
38Niklas
39Camille
40Dominique
41Marc
42Wyatt
43Isabelle
44Terhi
45Ladislav
46Hugh
47Lucas
48Johannes
49Stanisław
50Enrique
51Joakim
52Emma
53Phil
54Steve
55Mark
56Diego
57Luis
58Manoj
59Puja

Repeated IDs inside IN do not multiply outer rows. This positive membership test selects the same customers here; both keys are non-null.

For “show each customer and their spending”, a scalar aggregate expresses one summary per customer. The aggregate guarantees one result row. Beware scalar queries simply selecting an invoice amount: a customer can have many invoices, and engines differ in handling multi-row scalar results.

Scalar subquery

Show each customer’s total spending.

SQLite 3+ · Chinook
SELECT c.CustomerId, c.FirstName,
 COALESCE((SELECT ROUND(SUM(i.Total), 2)
 FROM Invoice AS i WHERE i.CustomerId = c.CustomerId), 0) AS Spending
FROM Customer AS c
ORDER BY c.CustomerId;
Show all 59 recordsShow first 10 records
Scalar subquery: expected result · Showing 10 of 59 records for clarity59 records
CustomerIdFirstNameSpending
1Luís39.62
2Leonie37.62
3François39.62
4Bjørn39.62
5František40.62
6Helena49.62
7Astrid42.62
8Daan37.62
9Kara37.62
10Eduardo37.62
11Alexandre37.62
12Roberto37.62
13Fernanda37.62
14Mark37.62
15Jennifer38.62
16Frank37.62
17Jack39.62
18Michelle37.62
19Tim38.62
20Dan39.62
21Kathy37.62
22Heather39.62
23John37.62
24Frank43.62
25Victor42.62
26Richard47.62
27Patrick37.62
28Julia43.62
29Robert37.62
30Edward37.62
31Martha37.62
32Aaron37.62
33Ellie37.62
34João39.62
35Madalena37.62
36Hannah37.62
37Fynn43.62
38Niklas37.62
39Camille38.62
40Dominique38.62
41Marc37.62
42Wyatt39.62
43Isabelle40.62
44Terhi41.62
45Ladislav45.62
46Hugh45.62
47Lucas37.62
48Johannes40.62
49Stanisław37.62
50Enrique37.62
51Joakim38.62
52Emma37.62
53Phil37.62
54Steve37.62
55Mark37.62
56Diego37.62
57Luis46.62
58Manoj38.62
59Puja36.64

All 59 customers have invoices here, so every spending total is positive. SUM without GROUP BY always returns one row; for no matches its value is NULL, converted to zero by COALESCE. SQLite takes the first row of a multi-row scalar subquery; many engines reject it. Use a query guaranteed to produce at most one value.

A subquery in FROM, a derived table, can establish the grain before a join. The inner grouping creates one row per customer, so the outer join preserves that summary grain. This is often clearer when you need several aggregates from the same input.

Subquery in FROM

Summarize invoices before joining customers.

SQLite 3+ · Chinook
SELECT c.CustomerId, c.FirstName, COALESCE(s.Spending, 0) AS Spending
FROM Customer AS c
LEFT JOIN (
 SELECT CustomerId, ROUND(SUM(Total), 2) AS Spending
 FROM Invoice GROUP BY CustomerId
) AS s ON s.CustomerId = c.CustomerId
ORDER BY c.CustomerId;
Show all 59 recordsShow first 10 records
Subquery in FROM: expected result · Showing 10 of 59 records for clarity59 records
CustomerIdFirstNameSpending
1Luís39.62
2Leonie37.62
3François39.62
4Bjørn39.62
5František40.62
6Helena49.62
7Astrid42.62
8Daan37.62
9Kara37.62
10Eduardo37.62
11Alexandre37.62
12Roberto37.62
13Fernanda37.62
14Mark37.62
15Jennifer38.62
16Frank37.62
17Jack39.62
18Michelle37.62
19Tim38.62
20Dan39.62
21Kathy37.62
22Heather39.62
23John37.62
24Frank43.62
25Victor42.62
26Richard47.62
27Patrick37.62
28Julia43.62
29Robert37.62
30Edward37.62
31Martha37.62
32Aaron37.62
33Ellie37.62
34João39.62
35Madalena37.62
36Hannah37.62
37Fynn43.62
38Niklas37.62
39Camille38.62
40Dominique38.62
41Marc37.62
42Wyatt39.62
43Isabelle40.62
44Terhi41.62
45Ladislav45.62
46Hugh45.62
47Lucas37.62
48Johannes40.62
49Stanisław37.62
50Enrique37.62
51Joakim38.62
52Emma37.62
53Phil37.62
54Steve37.62
55Mark37.62
56Diego37.62
57Luis46.62
58Manoj38.62
59Puja36.64

One summary row per CustomerId makes this a many-to-one join. The derived table is non-correlated: it never refers to c.

Compare equivalent lists and different row counts

The customer-only DISTINCT JOIN, EXISTS and positive IN return all 59 customer IDs. Their equivalence depends on selected columns and the question. The raw join includes InvoiceId and has 412 rows. Switch approaches and highlight a customer to follow multiplication. Without JavaScript, all alternatives remain visible.

Same population, different result grain

INNER JOIN: every matching invoice

Which customers have invoices? Inspect every match.

SQLite 3+ · Chinook
SELECT c.CustomerId, c.FirstName, i.InvoiceId
FROM Customer AS c
INNER JOIN Invoice AS i ON i.CustomerId = c.CustomerId
ORDER BY c.CustomerId, i.InvoiceId;
Show all 412 recordsShow first 10 records
INNER JOIN: every matching invoice: expected result · Showing 10 of 412 records for clarity412 records
CustomerIdFirstNameInvoiceId
1Luís98
1Luís121
1Luís143
1Luís195
1Luís316
1Luís327
1Luís382
2Leonie1
2Leonie12
2Leonie67
2Leonie196
2Leonie219
2Leonie241
2Leonie293
3François99
3François110
3François165
3François294
3François317
3François339
3François391
4Bjørn2
4Bjørn24
4Bjørn76
4Bjørn197
4Bjørn208
4Bjørn263
4Bjørn392
5František77
5František100
5František122
5František174
5František295
5František306
5František361
6Helena46
6Helena175
6Helena198
6Helena220
6Helena272
6Helena393
6Helena404
7Astrid78
7Astrid89
7Astrid144
7Astrid273
7Astrid296
7Astrid318
7Astrid370
8Daan3
8Daan55
8Daan176
8Daan187
8Daan242
8Daan371
8Daan394
9Kara56
9Kara79
9Kara101
9Kara153
9Kara274
9Kara285
9Kara340
10Eduardo25
10Eduardo154
10Eduardo177
10Eduardo199
10Eduardo251
10Eduardo372
10Eduardo383
11Alexandre57
11Alexandre68
11Alexandre123
11Alexandre252
11Alexandre275
11Alexandre297
11Alexandre349
12Roberto34
12Roberto155
12Roberto166
12Roberto221
12Roberto350
12Roberto373
12Roberto395
13Fernanda35
13Fernanda58
13Fernanda80
13Fernanda132
13Fernanda253
13Fernanda264
13Fernanda319
14Mark4
14Mark133
14Mark156
14Mark178
14Mark230
14Mark351
14Mark362
15Jennifer36
15Jennifer47
15Jennifer102
15Jennifer231
15Jennifer254
15Jennifer276
15Jennifer328
16Frank13
16Frank134
16Frank145
16Frank200
16Frank329
16Frank352
16Frank374
17Jack14
17Jack37
17Jack59
17Jack111
17Jack232
17Jack243
17Jack298
18Michelle112
18Michelle135
18Michelle157
18Michelle209
18Michelle330
18Michelle341
18Michelle396
19Tim15
19Tim26
19Tim81
19Tim210
19Tim233
19Tim255
19Tim307
20Dan113
20Dan124
20Dan179
20Dan308
20Dan331
20Dan353
20Dan405
21Kathy16
21Kathy38
21Kathy90
21Kathy211
21Kathy222
21Kathy277
21Kathy406
22Heather91
22Heather114
22Heather136
22Heather188
22Heather309
22Heather320
22Heather375
23John5
23John60
23John189
23John212
23John234
23John286
23John407
24Frank92
24Frank103
24Frank158
24Frank287
24Frank310
24Frank332
24Frank384
25Victor17
25Victor69
25Victor190
25Victor201
25Victor256
25Victor385
25Victor408
26Richard70
26Richard93
26Richard115
26Richard167
26Richard288
26Richard299
26Richard354
27Patrick39
27Patrick168
27Patrick191
27Patrick213
27Patrick265
27Patrick386
27Patrick397
28Julia71
28Julia82
28Julia137
28Julia266
28Julia289
28Julia311
28Julia363
29Robert48
29Robert169
29Robert180
29Robert235
29Robert364
29Robert387
29Robert409
30Edward49
30Edward72
30Edward94
30Edward146
30Edward267
30Edward278
30Edward333
31Martha18
31Martha147
31Martha170
31Martha192
31Martha244
31Martha365
31Martha376
32Aaron50
32Aaron61
32Aaron116
32Aaron245
32Aaron268
32Aaron290
32Aaron342
33Ellie27
33Ellie148
33Ellie159
33Ellie214
33Ellie343
33Ellie366
33Ellie388
34João28
34João51
34João73
34João125
34João246
34João257
34João312
35Madalena126
35Madalena149
35Madalena171
35Madalena223
35Madalena344
35Madalena355
35Madalena410
36Hannah29
36Hannah40
36Hannah95
36Hannah224
36Hannah247
36Hannah269
36Hannah321
37Fynn6
37Fynn127
37Fynn138
37Fynn193
37Fynn322
37Fynn345
37Fynn367
38Niklas7
38Niklas30
38Niklas52
38Niklas104
38Niklas225
38Niklas236
38Niklas291
39Camille105
39Camille128
39Camille150
39Camille202
39Camille323
39Camille334
39Camille389
40Dominique8
40Dominique19
40Dominique74
40Dominique203
40Dominique226
40Dominique248
40Dominique300
41Marc106
41Marc117
41Marc172
41Marc301
41Marc324
41Marc346
41Marc398
42Wyatt9
42Wyatt31
42Wyatt83
42Wyatt204
42Wyatt215
42Wyatt270
42Wyatt399
43Isabelle84
43Isabelle107
43Isabelle129
43Isabelle181
43Isabelle302
43Isabelle313
43Isabelle368
44Terhi53
44Terhi182
44Terhi205
44Terhi227
44Terhi279
44Terhi400
44Terhi411
45Ladislav85
45Ladislav96
45Ladislav151
45Ladislav280
45Ladislav303
45Ladislav325
45Ladislav377
46Hugh10
46Hugh62
46Hugh183
46Hugh194
46Hugh249
46Hugh378
46Hugh401
47Lucas63
47Lucas86
47Lucas108
47Lucas160
47Lucas281
47Lucas292
47Lucas347
48Johannes32
48Johannes161
48Johannes184
48Johannes206
48Johannes258
48Johannes379
48Johannes390
49Stanisław64
49Stanisław75
49Stanisław130
49Stanisław259
49Stanisław282
49Stanisław304
49Stanisław356
50Enrique41
50Enrique162
50Enrique173
50Enrique228
50Enrique357
50Enrique380
50Enrique402
51Joakim42
51Joakim65
51Joakim87
51Joakim139
51Joakim260
51Joakim271
51Joakim326
52Emma11
52Emma140
52Emma163
52Emma185
52Emma237
52Emma358
52Emma369
53Phil43
53Phil54
53Phil109
53Phil238
53Phil261
53Phil283
53Phil335
54Steve20
54Steve141
54Steve152
54Steve207
54Steve336
54Steve359
54Steve381
55Mark21
55Mark44
55Mark66
55Mark118
55Mark239
55Mark250
55Mark305
56Diego119
56Diego142
56Diego164
56Diego216
56Diego337
56Diego348
56Diego403
57Luis22
57Luis33
57Luis88
57Luis217
57Luis240
57Luis262
57Luis314
58Manoj120
58Manoj131
58Manoj186
58Manoj315
58Manoj338
58Manoj360
58Manoj412
59Puja23
59Puja45
59Puja97
59Puja218
59Puja229
59Puja284

412 invoice matches across 59 customers. Most customers have seven invoices; one has six. Repeated customer identifiers are the correct one-to-many result, not duplicate invoices.

JOIN with DISTINCT

Return each matching customer once.

SQLite 3+ · Chinook
SELECT DISTINCT c.CustomerId, c.FirstName
FROM Customer AS c
JOIN Invoice AS i ON i.CustomerId = c.CustomerId
ORDER BY c.CustomerId;
Show all 59 recordsShow first 10 records
JOIN with DISTINCT: expected result · Showing 10 of 59 records for clarity59 records
CustomerIdFirstName
1Luís
2Leonie
3François
4Bjørn
5František
6Helena
7Astrid
8Daan
9Kara
10Eduardo
11Alexandre
12Roberto
13Fernanda
14Mark
15Jennifer
16Frank
17Jack
18Michelle
19Tim
20Dan
21Kathy
22Heather
23John
24Frank
25Victor
26Richard
27Patrick
28Julia
29Robert
30Edward
31Martha
32Aaron
33Ellie
34João
35Madalena
36Hannah
37Fynn
38Niklas
39Camille
40Dominique
41Marc
42Wyatt
43Isabelle
44Terhi
45Ladislav
46Hugh
47Lucas
48Johannes
49Stanisław
50Enrique
51Joakim
52Emma
53Phil
54Steve
55Mark
56Diego
57Luis
58Manoj
59Puja

DISTINCT compares the entire selected row. Adding InvoiceId restores 412 distinct rows. Keep CustomerId: two people can share a name.

EXISTS: test for a match

Which customers have at least one invoice?

SQLite 3+ · Chinook
SELECT c.CustomerId, c.FirstName
FROM Customer AS c
WHERE EXISTS (
 SELECT 1 FROM Invoice AS i
 WHERE i.CustomerId = c.CustomerId
)
ORDER BY c.CustomerId;
Show all 59 recordsShow first 10 records
EXISTS: test for a match: expected result · Showing 10 of 59 records for clarity59 records
CustomerIdFirstName
1Luís
2Leonie
3François
4Bjørn
5František
6Helena
7Astrid
8Daan
9Kara
10Eduardo
11Alexandre
12Roberto
13Fernanda
14Mark
15Jennifer
16Frank
17Jack
18Michelle
19Tim
20Dan
21Kathy
22Heather
23John
24Frank
25Victor
26Richard
27Patrick
28Julia
29Robert
30Edward
31Martha
32Aaron
33Ellie
34João
35Madalena
36Hannah
37Fynn
38Niklas
39Camille
40Dominique
41Marc
42Wyatt
43Isabelle
44Terhi
45Ladislav
46Hugh
47Lucas
48Johannes
49Stanisław
50Enrique
51Joakim
52Emma
53Phil
54Steve
55Mark
56Diego
57Luis
58Manoj
59Puja

The correlation links invoices to the current outer customer. The value 1 is irrelevant: EXISTS tests the presence of a row.

IN: membership

Which customer IDs occur in Invoice?

SQLite 3+ · Chinook
SELECT CustomerId, FirstName
FROM Customer
WHERE CustomerId IN (SELECT CustomerId FROM Invoice)
ORDER BY CustomerId;
Show all 59 recordsShow first 10 records
IN: membership: expected result · Showing 10 of 59 records for clarity59 records
CustomerIdFirstName
1Luís
2Leonie
3François
4Bjørn
5František
6Helena
7Astrid
8Daan
9Kara
10Eduardo
11Alexandre
12Roberto
13Fernanda
14Mark
15Jennifer
16Frank
17Jack
18Michelle
19Tim
20Dan
21Kathy
22Heather
23John
24Frank
25Victor
26Richard
27Patrick
28Julia
29Robert
30Edward
31Martha
32Aaron
33Ellie
34João
35Madalena
36Hannah
37Fynn
38Niklas
39Camille
40Dominique
41Marc
42Wyatt
43Isabelle
44Terhi
45Ladislav
46Hugh
47Lucas
48Johannes
49Stanisław
50Enrique
51Joakim
52Emma
53Phil
54Steve
55Mark
56Diego
57Luis
58Manoj
59Puja

Repeated IDs inside IN do not multiply outer rows. This positive membership test selects the same customers here; both keys are non-null.

DISTINCT is appropriate for a distinct set of projected values. It is a poor emergency fix for an unexplained join: it can hide missing conditions or merge different customers sharing a name. Decide whether you need invoice detail, customer membership or a customer summary before removing repeats.

NULL, missing conditions and hidden multiplication

For “customers with no invoices”, NOT EXISTS states absence directly. Another approach is a left join followed by checking a non-nullable right-side key for NULL. Avoid checking an optional attribute: a real matching row might have a null attribute too.

NOT EXISTS

Who has no invoices?

SQLite 3+ · Chinook
SELECT c.CustomerId, c.FirstName
FROM Customer AS c
WHERE NOT EXISTS (
 SELECT 1 FROM Invoice AS i WHERE i.CustomerId = c.CustomerId
)
ORDER BY c.CustomerId;
NOT EXISTS: expected result · 0 records
CustomerIdFirstName

No rows returned.

No rows: all 59 customers in this database have at least one invoice. NOT EXISTS still expresses absence correctly; an empty result is the answer for this population.

Here every customer has an invoice, so the absence query returns no records. The LEFT JOIN example above uses the visible condition i.Total > 20 to demonstrate customers without a qualifying invoice. A missing correlation can also be hidden by a dataset where every customer already has a match.

NOT IN is not generally interchangeable with NOT EXISTS when the subquery can produce NULL. SQL has three-valued logic: true, false or unknown. To say “2 is not in this set”, every equality must be false. A null comparison leaves uncertainty, so the predicate does not pass WHERE.

A deliberately nullable synthetic set

NOT IN with NULL

Is 2 absent from a nullable set?

SQLite 3.8.3+ · Synthetic VALUES
WITH candidates(value) AS (VALUES (1), (NULL))
SELECT 2 AS Candidate
WHERE 2 NOT IN (SELECT value FROM candidates);
NOT IN with NULL: expected result · 0 records
Candidate

No rows returned.

No rows. The comparison involving NULL is unknown; WHERE retains only true predicates.

NOT EXISTS with NULL

Does any candidate equal 2?

SQLite 3.8.3+ · Synthetic VALUES
WITH candidates(value) AS (VALUES (1), (NULL))
SELECT 2 AS Candidate
WHERE NOT EXISTS (SELECT 1 FROM candidates WHERE value = 2);
NOT EXISTS with NULL: expected result · 1 record
Candidate
2

One row, 2. NULL does not equal 2. For nullable outer keys too, decide explicitly whether unknown values should count as matches.

If your rule excludes unknown values, explicitly filter nulls from the membership set and consider nullable outer values separately. If your rule is “no equal record exists”, equality-based NOT EXISTS expresses it directly. Neither form decides your business policy for missing identifiers.

Missing correlation: a hidden mistake

What happens if we forget the customer link?

SQLite 3+ · Chinook
SELECT c.CustomerId, c.FirstName
FROM Customer AS c
WHERE EXISTS (SELECT 1 FROM Invoice AS i)
ORDER BY c.CustomerId;
Show all 59 recordsShow first 10 records
Missing correlation: a hidden mistake: expected result · Showing 10 of 59 records for clarity59 records
CustomerIdFirstName
1Luís
2Leonie
3François
4Bjørn
5František
6Helena
7Astrid
8Daan
9Kara
10Eduardo
11Alexandre
12Roberto
13Fernanda
14Mark
15Jennifer
16Frank
17Jack
18Michelle
19Tim
20Dan
21Kathy
22Heather
23John
24Frank
25Victor
26Richard
27Patrick
28Julia
29Robert
30Edward
31Martha
32Aaron
33Ellie
34João
35Madalena
36Hannah
37Fynn
38Niklas
39Camille
40Dominique
41Marc
42Wyatt
43Isabelle
44Terhi
45Ladislav
46Hugh
47Lucas
48Johannes
49Stanisław
50Enrique
51Joakim
52Emma
53Phil
54Steve
55Mark
56Diego
57Luis
58Manoj
59Puja

All 59 customers appear because some invoice exists anywhere. Every customer happens to have an invoice in this database, so the data hides this missing-correlation bug. The predicate would also admit a newly added customer with no invoices.

Missing join condition

How many rows does every possible combination produce?

SQLite 3+ · Chinook
SELECT COUNT(*) AS Combinations
FROM Customer AS c CROSS JOIN Invoice AS i;
Missing join condition: expected result · 1 record
Combinations
24308

59 × 412 = 24,308. CROSS JOIN makes the Cartesian product explicit; a forgotten predicate in an implicit comma join does the same.

Two independent one-to-many joins can be worse: joining a customer’s invoices and support tickets produces every invoice–ticket pair for that customer. Aggregates may overcount both. Aggregate each relationship to the desired grain first, or use independent existence checks. More nesting is not automatically safer: it should clarify a real dependency.

Which is faster? Inspect the actual plan

There is no universal winner. Optimisers can transform some membership and existence tests into semi-join strategies: preserve a left row if a match exists without outputting every matching pair. Correlation can become an efficient indexed lookup; it can also cause repeated scans. Available rewrites depend on the engine, query shape, nullability and statistics.

An index beginning with Invoice.CustomerId may help equality probes or grouped summaries. Selectivity matters: if nearly all customers qualify, scanning may be cheaper. JOIN plus DISTINCT may require sorting or a temporary structure, but another engine may avoid it. Derived aggregates may materialize or flatten. Read the plan rather than inferring execution from indentation.

  1. Verify identical semantics, including duplicates and NULL.
  2. Inspect SQLite EXPLAIN QUERY PLAN or your engine’s equivalent for scans, index searches and temporary structures.
  3. Measure representative row counts, skew, indexes and current statistics. Record engine/version and relevant warm/cold cache conditions.
  4. Recheck correctness after tuning. A faster query dropping unmatched customers answers a different question.

This sample database teaches semantics; it is not a production benchmark. No timing claims are made. Adding an index here would not establish production performance.

A practical decision guide

Choose by the shape of the answer
NeedStarting pointCheck
Columns on every matchINNER JOINOne-to-many multiplication
Unmatched left records tooLEFT JOINON versus WHERE
At least one related recordEXISTSCorrect correlation
Single-column membershipINNULL policy for negation
No equal related recordNOT EXISTSNullable-key business rules
One summary per outer rowScalar aggregateCardinality and missing values
Several summaries at known grainDerived table + JOINUnique grouping key

For the opening question, EXISTS communicates intent directly. If you next need invoice dates and amounts, a join is natural. To preserve invoice details while adding customer totals, a window function may fit better than a grouped join or repeated scalar expression.

Practise: state the grain first

Use the complete database unchanged. Predict which customers have invoices over 20 and which should receive a zero count before revealing a solution.

Beginner · qualifying customers

Find each customer with an invoice over 20, once.

Hint

You need membership, not invoice columns. Put the key comparison and amount condition inside EXISTS.

Solution and checked result

Beginner solution

Find each customer with an invoice over 20, once.

SQLite 3+ · Chinook
SELECT c.CustomerId, c.FirstName
FROM Customer AS c
WHERE EXISTS (
 SELECT 1 FROM Invoice AS i
 WHERE i.CustomerId = c.CustomerId AND i.Total > 20
)
ORDER BY c.CustomerId;
Beginner solution: expected result · 4 records
CustomerIdFirstName
6Helena
26Richard
45Ladislav
46Hugh

Intermediate · count qualifying invoices including zero

Count invoices over 20 for every customer, including zero.

Hint

Put the amount condition in ON to preserve customers without a qualifying invoice. Count the right-side primary key, not the null-extended row.

Solution and checked result

Intermediate solution

Count invoices over 20 for every customer, including zero.

SQLite 3+ · Chinook
SELECT c.CustomerId, COUNT(i.InvoiceId) AS InvoiceCount
FROM Customer AS c
LEFT JOIN Invoice AS i ON i.CustomerId = c.CustomerId AND i.Total > 20
GROUP BY c.CustomerId
ORDER BY c.CustomerId;
Show all 59 recordsShow first 10 records
Intermediate solution: expected result · Showing 10 of 59 records for clarity59 records
CustomerIdInvoiceCount
10
20
30
40
50
61
70
80
90
100
110
120
130
140
150
160
170
180
190
200
210
220
230
240
250
261
270
280
290
300
310
320
330
340
350
360
370
380
390
400
410
420
430
440
451
461
470
480
490
500
510
520
530
540
550
560
570
580
590

Only four customers have a qualifying invoice. COUNT(i.InvoiceId) correctly returns zero for each null-extended row; COUNT(*) would incorrectly return one for the other 55 customers.

Advanced · define the population

Find invoiced customers spending more than the mean of invoiced customers.

Hint

Group Invoice to customer spending. Average those grouped amounts; do not average invoices.

Solution and checked result

Advanced solution

Find invoiced customers spending more than the mean of invoiced customers.

SQLite 3.8.3+ · Chinook
WITH spending AS (
 SELECT CustomerId, SUM(Total) AS Amount
 FROM Invoice GROUP BY CustomerId
)
SELECT CustomerId, ROUND(Amount, 2) AS Spending
FROM spending
WHERE Amount > (SELECT AVG(Amount) FROM spending)
ORDER BY CustomerId;
Show all 22 recordsShow first 10 records
Advanced solution: expected result · Showing 10 of 22 records for clarity22 records
CustomerIdSpending
139.62
339.62
439.62
540.62
649.62
742.62
1739.62
2039.62
2239.62
2443.62
2542.62
2647.62
2843.62
3439.62
3743.62
4239.62
4340.62
4441.62
4545.62
4645.62
4840.62
5746.62

The denominator is all 59 invoiced customers. No customer is omitted from that population here. In a database with customers without invoices, including their zero spending would change the business question and average.

References and next steps

Download every example and solution · Next: Window Functions Explained · SQL & Data Skills.

Corrections: report an article issue with the query, SQLite version and expected result. AI-assisted writing with automated SQL checks and independent technical review.