There's a family of questions that needs no value at all: just a yes or a no. Has this customer ever bought? Does this product have any review? Does this order contain any cosmetics item? It doesn't matter how many, or which, or how much they add up to: what matters is whether at least one exists.
EXISTS is the operator that answers exactly that, and it's probably the subquery you'll write most often in your professional life. Its negation, NOT EXISTS, solves the other half —customers who haven't bought, products nobody has sold— and it does so with a property none of the alternatives has: it's immune to null values. In this lesson you'll see both, you'll compare them with NOT IN, with 03-03's anti-join and with 03-07's EXCEPT, and you'll close with the classic problem of relational division.
Contents
EXISTS: a predicate, not a value- Why it makes no difference what you put in the inner
SELECT - Four GreenStore cases with
EXISTS NOT EXISTS: the data set's four gapsNOT EXISTSagainstNOT IN: the difference that matters- The four ways of answering "what doesn't match"
- Short-circuiting:
EXISTSagainstCOUNT(*) > 0 - Relational division: the double negation
- Common Mistakes and Tips
- Exercises
- Conclusion
EXISTS: a predicate, not a value
EXISTS: a predicate, not a valueEXISTS (subquery) is a predicate: it returns TRUE or FALSE, never NULL. Its rule is radically simple:
EXISTSisTRUEthe moment the subquery produces its first row. If it produces none, it'sFALSE.
SELECT c.id, c.name, c.last_name
FROM customers AS c
WHERE EXISTS (SELECT 1 FROM orders AS o WHERE o.customer_id = c.id)
ORDER BY c.id
LIMIT 4;| id | name | last_name |
|---|---|---|
| 1 | Lucía | Martínez Soler |
| 2 | Carlos | Ferrer Ibáñez |
| 3 | Marta | Sanchis Gil |
| 4 | Javier | Ortega Ruiz |
(First 4 of 12 rows: the 12 customers who have bought at some point.)
Three properties set it apart from everything you've seen: it returns TRUE/FALSE and never NULL, so it's immune to 04-03's three-valued logic; it doesn't look at the contents of the subquery, whatever columns it returns; and it stops at the first row, it doesn't count or sum or sort.
And a fourth, decisive one: in practice EXISTS is always correlated. An uncorrelated EXISTS —WHERE EXISTS (SELECT 1 FROM orders)— is TRUE or FALSE for every row alike, so it returns either the whole table or no rows at all. It isn't an error, but it isn't any use either. The condition o.customer_id = c.id is what does the work: it turns "are there orders?" into "are there orders for this customer?".
- Why it makes no difference what you put in the inner
SELECT
SELECTThese four forms are exactly equivalent and produce the same execution plan:
WHERE EXISTS (SELECT 1 FROM orders AS o WHERE o.customer_id = c.id)
WHERE EXISTS (SELECT * FROM orders AS o WHERE o.customer_id = c.id)
WHERE EXISTS (SELECT o.id FROM orders AS o WHERE o.customer_id = c.id)
WHERE EXISTS (SELECT 1/0 FROM orders AS o WHERE o.customer_id = c.id)The last one is the proof: 1/0 is a division by zero and it raises no error, because PostgreSQL never evaluates the column list of an EXISTS. It only checks whether the query produces rows. The projection is discarded before being computed.
Hence the SELECT 1 idiom, the one you'll see in 90 % of professional code: it's the shortest way of saying "I'm not interested in the contents". SELECT * is just as correct and some style guides prefer it because it stresses the same thing. Pick one and be consistent; in this course we use SELECT 1.
What does matter inside the
EXISTSis theWHERE: the correlation. AnEXISTSwhoseWHEREdoesn't mention the outer row is almost certainly badly framed.
- Four GreenStore cases with
EXISTS
EXISTSProducts with at least one review. The EXISTS doesn't say how many or with what rating: only that there is one.
SELECT p.id, p.name AS product, p.price
FROM products AS p
WHERE EXISTS (SELECT 1 FROM reviews AS r WHERE r.product_id = p.id)
ORDER BY p.id;| id | product | price |
|---|---|---|
| 1 | Extra virgin olive oil 500 ml | 12.50 |
| 2 | Organic brown rice 1 kg | 3.90 |
| 5 | Organic crushed tomato 400 g | 1.95 |
| 6 | Aloe vera face cream 50 ml | 18.90 |
| 10 | Concentrated eco laundry detergent 1 L | 11.20 |
(First 5 of 9 rows; the other four are products 12, 15, 16 and 18.) 9 of the 20 products. Compare it with an INNER JOIN on reviews: that would have returned 12 rows, one per review, with the oil, the rice and the cream repeated. EXISTS never multiplies rows, and that's its most practical advantage over the JOIN.
Orders containing some natural cosmetics product. Here the subquery carries its own JOIN:
SELECT o.id AS order_id, o.order_date, c.name || ' ' || c.last_name AS customer, o.status
FROM orders AS o
JOIN customers AS c ON o.customer_id = c.id
WHERE EXISTS (SELECT 1
FROM order_lines AS ol
JOIN products AS p ON p.id = ol.product_id
WHERE ol.order_id = o.id
AND p.category_id = 2)
ORDER BY o.id;| order_id | order_date | customer | status |
|---|---|---|---|
| 2 | 2025-03-12 | Carlos Ferrer Ibáñez | delivered |
| 6 | 2025-05-23 | Ana Belmonte Roca | cancelled |
| 9 | 2025-07-15 | Tiago Almeida Nunes | delivered |
| 10 | 2025-08-03 | Camille Dubois | delivered |
| 15 | 2025-12-02 | Lucía Martínez Soler | delivered |
| 18 | 2026-01-27 | Ana Belmonte Roca | paid |
6 orders out of 20. Order 9 contains two cosmetics items (shampoo and lip balm) and appears only once: with a JOIN 7 rows would have come out and a DISTINCT would have been needed. Employees who have handled at least one order, same pattern:
SELECT e.id, e.name || ' ' || e.last_name AS employee, e.job_title
FROM employees AS e
WHERE EXISTS (SELECT 1 FROM orders AS o WHERE o.employee_id = e.id)
ORDER BY e.id;| id | employee | job_title |
|---|---|---|
| 4 | Óscar Peris Blasco | Sales rep |
| 5 | Laia Puig Sanchis | Sales rep |
| 6 | Marc Estévez Roig | Customer support |
3 of the 8 employees, exactly the ones 01-06 announced.
Dialect note: in PostgreSQL
EXISTSreturns a genuineboolean, so you can use it as a column:SELECT c.id, EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id) AS has_bought FROM customers creturnstrue/falsefor all 15 customers. SQL Server and Oracle don't allow it and force you to wrap it in aCASE WHEN EXISTS (...) THEN 1 ELSE 0 END. MySQL 8 and SQLite do accept it, returning 1 or 0.
NOT EXISTS: the data set's four gaps
NOT EXISTS: the data set's four gapsNOT EXISTS is the literal negation: TRUE when the subquery produces no rows at all. It's the natural tool for every question with "without", "never" or "none", and it answers GreenStore's four deliberate gaps with the same template.
-- Customers who have never bought
SELECT c.id, c.name, c.last_name, c.city, c.signup_date
FROM customers AS c
WHERE NOT EXISTS (SELECT 1 FROM orders AS o WHERE o.customer_id = c.id)
ORDER BY c.id;| id | name | last_name | city | signup_date |
|---|---|---|---|---|
| 13 | Núria | Bosch Ferrer | Barcelona | 2025-06-20 |
| 14 | Hugo | Iglesias Pardo | Zaragoza | 2025-09-12 |
| 15 | Inés | Carrasco Vega | Valencia | 2026-01-08 |
And the same template, changing only the inner table, gives the products never sold:
SELECT p.id, p.name, p.price, p.stock, p.active
FROM products AS p
WHERE NOT EXISTS (SELECT 1 FROM order_lines AS ol WHERE ol.product_id = p.id)
ORDER BY p.id;| id | name | price | stock | active |
|---|---|---|---|---|
| 13 | Soy wax candles (pack of 2) | 13.75 | 0 | true |
| 19 | Natural stick deodorant 50 g | 7.80 | 75 | true |
| 20 | Spirulina capsules 120 units | 16.40 | 55 | false |
With NOT EXISTS (SELECT 1 FROM reviews AS r WHERE r.product_id = p.id) over products you get the 11 products with no review, and with NOT EXISTS (SELECT 1 FROM orders AS o WHERE o.employee_id = e.id) over employees, the 5 employees with no orders (Rosa, Andrés, Beatriz, Irene and Daniel).
Four business questions, a single template. That uniformity is the main reason to prefer NOT EXISTS: there's no need to decide which column to check with IS NULL, nor to worry about nulls, nor to remember which table goes on the left.
NOT EXISTS against NOT IN: the difference that matters
NOT EXISTS against NOT IN: the difference that mattersNow the module's central demonstration. The same question, two forms, two different results.
-- ⚠️ INCORRECT: 0 rows
SELECT e.id, e.name, e.last_name
FROM employees AS e
WHERE e.id NOT IN (SELECT employee_id FROM orders);
-- ✅ CORRECT: 5 rows
SELECT e.id, e.name, e.last_name
FROM employees AS e
WHERE NOT EXISTS (SELECT 1 FROM orders AS o WHERE o.employee_id = e.id);| id | name | last_name |
|---|---|---|
| 1 | Rosa | Alcázar Vives |
| 2 | Andrés | Company Talens |
| 3 | Beatriz | Nadal Ripoll |
| 7 | Irene | Salvador Mira |
| 8 | Daniel | Vercher Lluch |
The first returns zero rows and the second five. The cause, which you already diagnosed in 07-01, is the 10 web-channel orders with employee_id set to NULL: the list is {4, 5, 6, NULL} and 1 <> NULL is UNKNOWN, so NOT IN can't be TRUE for any row. NOT EXISTS doesn't suffer the same fate because it doesn't compare the outer value against a list: it asks whether the subquery produces rows. For employee 1, SELECT 1 FROM orders WHERE employee_id = 1 produces none —the rows with a null employee_id don't satisfy that equality either and are discarded like any other that doesn't match— and NOT EXISTS is TRUE. Three-valued logic acts inside the subquery, where it only decides which rows come back, and it never escapes into the predicate.
flowchart LR
A["e.id = 1"] --> B{"NOT IN (4,5,6,NULL)"}
B --> C["UNKNOWN → ❌ discarded"]
A --> D{"NOT EXISTS<br/>(orders with employee_id = 1)"}
D --> E["0 rows → TRUE → ✅ kept"]
The rule, in one sentence:
NOT EXISTSis safe with nulls andNOT INisn't. If you pickNOT EXISTSout of habit, you'll never have to wonder whether the subquery's column is nullable.
- The four ways of answering "what doesn't match"
In 03-07 it was announced that the four forms would be compared in module 7. Here they are, solving the same question —customers who have never bought— and all returning the same 3 rows (13, 14 and 15):
-- 1. NOT EXISTS
SELECT c.id, c.name FROM customers AS c
WHERE NOT EXISTS (SELECT 1 FROM orders AS o WHERE o.customer_id = c.id);
-- 2. NOT IN
SELECT c.id, c.name FROM customers AS c
WHERE c.id NOT IN (SELECT customer_id FROM orders);
-- 3. Anti-join (03-03)
SELECT c.id, c.name FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.id WHERE o.id IS NULL;
-- 4. EXCEPT (03-07)
SELECT id FROM customers EXCEPT SELECT customer_id FROM orders;NOT EXISTS |
NOT IN |
Anti-join | EXCEPT |
|
|---|---|---|---|---|
Behaviour with NULL |
Safe | Dangerous: 0 rows | Safe | Safe (treats NULL as just another value) |
| Columns it can return | All the outer ones | All the outer ones | All the outer ones | Only the compared ones |
| Does it remove duplicates? | It creates none | It creates none | It creates none | Yes, always |
| Readability | High: it reads like the question | Very high… until it fails | Medium: you have to know the idiom | High, but limited |
| Typical performance | Anti-join in the plan | Worse if nulls are possible | Anti-join in the plan | Needs sorting/dedup |
| Portability | Universal | Universal | Universal | EXCEPT doesn't exist in MySQL 5.7 nor in Oracle (there it's MINUS) |
The course's recommendation: use NOT EXISTS. It's safe, universal, it leaves all the outer table's columns available and it reads just like the business question. The anti-join is equally idiomatic and sometimes more natural if you were already joining those tables. EXCEPT, only when you're comparing sets of the same shape and don't need more columns. And NOT IN, only if you can guarantee the subquery's column is NOT NULL — and even then, you gain nothing. This table is closed off and extended with the performance criterion in 07-05.
- Short-circuiting:
EXISTS against COUNT(*) > 0
EXISTS against COUNT(*) > 0It's tempting to write "is there any?" as "is the count greater than zero?". It works and gives the same result —12 rows both— but not the same work:
-- ⚠️ Correct but worse
SELECT c.id, c.name FROM customers AS c
WHERE (SELECT COUNT(*) FROM orders AS o WHERE o.customer_id = c.id) > 0;
-- ✅ Preferable
SELECT c.id, c.name FROM customers AS c
WHERE EXISTS (SELECT 1 FROM orders AS o WHERE o.customer_id = c.id);COUNT(*) > 0 |
EXISTS |
|
|---|---|---|
| Rows the subquery reads | All the customer's | One: it stops at the first |
| With a customer of 50,000 orders | Counts 50,000 | Reads 1 |
| What it tells the reader | "how many there are, and compare it" | "is there any?" |
EXISTS short-circuits: as soon as it finds a row, it stops looking; COUNT can't, because to know how many there are it has to see them all. With 20 orders the difference is unmeasurable; with real tables it separates an instant query from a full scan. And there's a third argument, the most important day to day: EXISTS says what you mean. The same goes for the negation: COUNT(*) = 0 is NOT EXISTS written in a more expensive way.
- Relational division: the double negation
We arrive at the classic problem. The question looks innocent:
Which customers have bought products from every category?
And it can't be answered with a plain EXISTS, because EXISTS talks about "some", not about "all". The trick is to reformulate the sentence until the "all" becomes two chained "nones":
That reformulation —which in logic is ∀x P(x) ≡ ¬∃x ¬P(x)— translates literally into SQL:
SELECT c.id, c.name || ' ' || c.last_name AS customer
FROM customers AS c
WHERE NOT EXISTS ( -- there is no category…
SELECT 1
FROM categories AS cat
WHERE NOT EXISTS ( -- …from which this customer hasn't bought
SELECT 1
FROM orders AS o
JOIN order_lines AS ol ON ol.order_id = o.id
JOIN products AS p ON p.id = ol.product_id
WHERE o.customer_id = c.id
AND p.category_id = cat.id))
ORDER BY c.id;No customer at all, and the result isn't disappointing but diagnostic: Supplements has never sold anything (€0.00 in the course's figures), so nobody can have bought from all six categories. The query is correct; what's wrong is the question. Reframed over the four main categories —Food, Natural cosmetics, Sustainable home and Drinks— it's enough to narrow the middle subquery:
| id | customer |
|---|---|
| 1 | Lucía Martínez Soler |
A single customer. Lucía is the only one who has bought from the four main categories, and she's managed it with three very different orders: number 1 (oil, rice and chamomile tea), number 5 (detergent, scrubber and bags) and number 15 (honey, shampoo and scrubber). It's exactly the profile a marketing team would want to identify: the customer who has explored the entire catalogue.
How to read this query without getting dizzy
Read it from the inside outwards, in three sentences:
| Level | What it asks | For whom |
|---|---|---|
Inner (NOT EXISTS 2) |
"has this customer not bought anything from this category?" | Each customer-category pair |
Middle (FROM categories) |
"is there any category satisfying the above?" | Each customer |
Outer (NOT EXISTS 1) |
"does none satisfy it?" → then they bought from them all | Each customer |
And a warning: there are two correlations in play. The inner one uses c.id (two levels outwards) and cat.id (one). If you forget one, the query is still valid and returns nonsense. Test the inner subquery with fixed values (c.id = 1, cat.id = 3) before assembling the three layers.
Note: there's a more readable alternative with
GROUP BYandHAVING COUNT(DISTINCT p.category_id) = 4, shorter and often faster. The double negation deserves to be understood because it's the only one that works when the reference set isn't a simple count —"all the active categories", "all the products in a catalogue that changes"— and because it's the idiom you'll recognise when reading other people's code.
Common Mistakes and Tips
- Writing an
EXISTSwith no correlation.WHERE EXISTS (SELECT 1 FROM orders)isTRUEfor every row and returns the whole table: the condition linking to the outer row is the essential part. - Believing that
SELECT *inside anEXISTSis slower. It isn't: the column list is never evaluated, andSELECT 1andSELECT *produce the same plan. - Using
NOT INover a nullable column. Zero rows, with no warning at all;NOT EXISTSnever has that problem. And writingCOUNT(*) > 0counts every row to answer something the first row settles. - Putting an
ORDER BYor aLIMITinside anEXISTS. They don't change the result and they add work. And confusingNOT EXISTSwithEXISTS (... WHERE NOT ...). "Has no delivered order" isNOT EXISTS (... status = 'delivered'); "has some non-delivered order" isEXISTS (... status <> 'delivered'). They're different questions. - Tip: translate the question word by word. "Customers who have bought" →
EXISTS. "Customers who have never bought" →NOT EXISTS. "Customers who have bought from all" →NOT EXISTS ( ... NOT EXISTS ( ... )). And there, be careful not to forget one of the two correlations: it raises no error and the result is rubbish. - Tip: use
EXISTSwhen theJOINwould force you into aDISTINCT. If you only want to know whether there's a relationship and you need no data from the other table, avoid the row multiplication at the root. And always test subqueries with fixed values —replacec.idwith1— before wiring up the correlation.
Exercises
Exercise 1
Customer support wants an opinions campaign aimed at customers who have bought at some point but have never written a review. Write the query with EXISTS and NOT EXISTS in the same WHERE clause, showing id, full name, country and number of orders.
Then answer: how many customers are left out by each of the two conditions?
Exercise 2
A colleague needs the orders that contain no food product at all and has written this:
-- ⚠️ Suspicious
SELECT DISTINCT o.id
FROM orders AS o
JOIN order_lines AS ol ON ol.order_id = o.id
JOIN products AS p ON p.id = ol.product_id
WHERE p.category_id <> 1;- Explain why it's wrong and what question it really answers.
- Write it correctly with
NOT EXISTSand give the number of rows. - Could you solve it with an anti-join? And with
NOT IN? Justify whether they'd be safe.
Exercise 3
Purchasing wants to know which suppliers have no unsold product: the ones whose references have all been sold at least once. Write it with a double NOT EXISTS. (Hint: same structure as relational division, but the reference set is the supplier's own products.)
Solutions
Solution 1
SELECT c.id,
c.name || ' ' || c.last_name AS customer,
c.country,
(SELECT COUNT(*) FROM orders AS o WHERE o.customer_id = c.id) AS orders
FROM customers AS c
WHERE EXISTS (SELECT 1 FROM orders AS o WHERE o.customer_id = c.id)
AND NOT EXISTS (SELECT 1 FROM reviews AS r WHERE r.customer_id = c.id)
ORDER BY c.id;| id | customer | country | orders |
|---|---|---|---|
| 5 | Ana Belmonte Roca | Spain | 2 |
| 10 | Julien Moreau | France | 1 |
| 12 | Diego Ramos Herrera | Spain | 1 |
3 customers. How the 15 break down:
| Condition | Discards | Who |
|---|---|---|
EXISTS (orders) |
3 | Núria, Hugo and Inés: they've never bought, they have nothing to review |
NOT EXISTS (reviews) |
9 | The 9 customers who have already written some review |
| Survivors | 3 | Ana, Julien and Diego |
All three are perfect targets: they've bought, they're satisfied or we don't know, and we've never asked them for their opinion. Notice that the two conditions are inseparable: with NOT EXISTS (reviews) alone, 6 customers would come out, including three who have bought nothing.
Solution 2
1. What's wrong. The query answers "orders containing some product that isn't food", which is almost the opposite. An order with oil (category 1) and kombucha (category 4) has a line satisfying category_id <> 1, so it shows up — even though it does carry food. The mistake is one of quantifier: a JOIN's WHERE filters lines, and the question is about orders. The DISTINCT disguises the symptom (the repeated rows) without touching the cause.
2. The correct version:
-- ✅ CORRECT
SELECT o.id AS order_id, o.order_date, o.status
FROM orders AS o
WHERE NOT EXISTS (SELECT 1
FROM order_lines AS ol
JOIN products AS p ON p.id = ol.product_id
WHERE ol.order_id = o.id
AND p.category_id = 1)
ORDER BY o.id;| order_id | order_date | status |
|---|---|---|
| 2 | 2025-03-12 | delivered |
| 5 | 2025-05-07 | delivered |
| 7 | 2025-06-11 | delivered |
| 9 | 2025-07-15 | delivered |
| 10 | 2025-08-03 | delivered |
(First 5 of 10 rows; the others are orders 12, 13, 16, 18 and 19.) 10 orders out of 20 carry not a single food product. The original query returned 18 —every order with some line from another category— and eight of them do buy food. Notice the change of logic: the condition p.category_id = 1 is written in the positive inside the EXISTS, and the NOT supplies the negation. That's the pattern of every question with "none".
3. With an anti-join, yes: FROM orders o LEFT JOIN (order_lines ol JOIN products p ON p.id = ol.product_id AND p.category_id = 1) ON ol.order_id = o.id WHERE ol.id IS NULL works, but you have to carry the category condition into the ON (if it goes in the WHERE it degrades the LEFT to an INNER, 03-03), which makes it considerably more fragile to write. With NOT IN it would also work —o.id NOT IN (SELECT ol.order_id FROM order_lines ol JOIN products p ON ... WHERE p.category_id = 1)— and it would be safe because order_lines.order_id is NOT NULL. But you'd still be depending on a schema guarantee that could change tomorrow: NOT EXISTS depends on nothing.
Solution 3
SELECT s.id, s.name AS supplier, s.country
FROM suppliers AS s
WHERE NOT EXISTS (
SELECT 1
FROM products AS p
WHERE p.supplier_id = s.id
AND NOT EXISTS (SELECT 1 FROM order_lines AS ol
WHERE ol.product_id = p.id))
ORDER BY s.id;| id | supplier | country |
|---|---|---|
| 1 | Huerta del Turia | Spain |
| 2 | BioSierra Ibérica | Spain |
| 3 | Verde Atlántico | Portugal |
3 suppliers out of 5. It squares with the three products never sold: number 19 (Deodorant) is from Maison Nature, and number 13 (Candles) and number 20 (Spirulina) are from EcoNordic Supplies. Those two are left out; the other three have placed their entire catalogue.
The structure is identical to section 8's with a single difference: the reference set is correlated (p.supplier_id = s.id) instead of being the whole categories table. It's the most useful variant of the pattern in practice, and its literal reading is "there is no product of theirs for which no sale exists".
Conclusion
EXISTS is the subquery you'll write the most:
EXISTSis a predicate: it returnsTRUEthe moment the subquery produces one row, and neverNULL. It doesn't look at the innerSELECT's columns —hence theSELECT 1idiom, and evenSELECT 1/0works— and it short-circuits.- In practice it's always correlated: the condition linking it to the outer row is what gives it meaning.
- It doesn't multiply rows. The 9 products with a review come out 9 times, not 12; the 6 orders with cosmetics come out 6, not 7. Where a
JOINwould call for aDISTINCT,EXISTSdoesn't need one. NOT EXISTSsolves GreenStore's four gaps with a single template: 3 customers with no purchase, 3 products never sold, 11 products with no review and 5 employees with no orders. And above all: it's safe with nulls andNOT INisn't — the same question gives 0 rows withNOT INand 5 withNOT EXISTS, because of the 10 web orders with a nullemployee_id.- You know the four ways of answering "what doesn't match" —
NOT EXISTS,NOT IN, anti-join andEXCEPT— with their differences in nulls, available columns, duplicates and portability; the recommendation isNOT EXISTS. And you can translate an "all" with the double negation: no customer has bought from the six categories (Supplements never sold anything) and only Lucía has done so from the four main ones.
In the next lesson, subqueries in SELECT, FROM and WHERE, you'll stop looking at the what to look at the where: what changes depending on the clause you place the subquery in, why a derived table in the FROM needs an alias without exception, how you aggregate twice in a row to reach at last the €36.40 average order value from its source, and how two aggregates of different granularity are crossed so that the €727.95 of product and the €118.25 of shipping add up without inflating each other.
SQL Course
Module 1: Introduction to SQL
- What is SQL?
- Setting up your SQL environment
- Basic SQL syntax
- Understanding databases and tables
- The relational model: primary and foreign keys
- The course database: GreenStore
Module 2: Basic SQL queries
- The SELECT statement
- Aliases, expressions and calculated columns
- Filtering data with WHERE
- DISTINCT and removing duplicates
- Sorting data with ORDER BY
- Limiting results with LIMIT
Module 3: Working with multiple tables
- JOIN operations
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN and CROSS JOIN
- Set operations: UNION, INTERSECT and EXCEPT
Module 4: Advanced data filtering
- Using LIKE for pattern matching
- The IN and BETWEEN operators
- NULL values and IS NULL
- Aggregate functions: COUNT, SUM, AVG, MIN and MAX
- Aggregating data with GROUP BY
- The HAVING clause
Module 5: Data manipulation
- Creating tables and constraints with CREATE TABLE
- The INSERT statement
- The UPDATE statement
- The DELETE statement
- The UPSERT (MERGE) statement
- Changing the schema: ALTER TABLE and safe migrations
Module 6: Advanced SQL functions
- String functions
- Numeric functions
- Date and time functions
- Type conversion and handling NULL: CAST and COALESCE
- Conditional expressions
Module 7: Subqueries and nested queries
- Introduction to subqueries
- Correlated subqueries
- EXISTS and NOT EXISTS
- Using subqueries in SELECT, FROM and WHERE
- Subquery or JOIN: which one to choose
Module 8: Indexes and performance tuning
- Understanding indexes
- Creating and managing indexes
- Index types and when not to index
- Query optimization techniques
- Analyzing query performance
Module 9: Transactions and concurrency
- Introduction to transactions
- ACID properties
- Transaction control statements
- Isolation levels and concurrency anomalies
- Handling concurrency: locks and deadlocks
Module 10: Advanced topics
- Views
- Common table expressions (CTEs)
- Window functions
- Stored procedures
- Triggers
- JSON and semi-structured data
Module 11: SQL in practice
- Real-world use cases
- Best practices
- Security: SQL injection, permissions and roles
- SQL for data analysis
- SQL in web development
