The FULL OUTER JOIN completes the outer-join family: it keeps every row of both tables, matching or not. It's a LEFT JOIN and a RIGHT JOIN in a single operation.
It's also, by some margin, the JOIN you'll write least often. And for an interesting reason this lesson explains: in a database with properly declared referential integrity, like GreenStore's, orphans can only exist on one side. A FULL OUTER JOIN between two tables related by a foreign key almost always degenerates into a LEFT JOIN. Its real playing field is somewhere else: the reconciliation of two independent data sources, where neither of the two has authority over the other.
Contents
- The rule and the set diagram
FULL OUTER JOINover GreenStore: why orphans only appear on one side- The real case: reconciling two data sources
- The "only what doesn't match on either side" pattern
- Emulation on engines that don't support it
- Support by engine, and cost
- Common Mistakes and Tips
- Exercises
- Conclusion
- The rule and the set diagram
flowchart LR
subgraph R[" "]
direction LR
A(("only on the left<br/>✅ with NULL on the right"))
I(("they match<br/>✅ result"))
B(("only on the right<br/>✅ with NULL on the left"))
end
style A fill:#d9f0d9,stroke:#2b7a2b,stroke-width:3px
style I fill:#d9f0d9,stroke:#2b7a2b,stroke-width:3px
style B fill:#d9f0d9,stroke:#2b7a2b,stroke-width:3px
The complete picture of the family, now with all four types:
| Type | Orphan on the left | They match | Orphan on the right | Rows in the result |
|---|---|---|---|---|
INNER JOIN |
❌ | ✅ | ❌ | matches |
LEFT JOIN |
✅ | ✅ | ❌ | matches + left orphans |
RIGHT JOIN |
❌ | ✅ | ✅ | matches + right orphans |
FULL OUTER JOIN |
✅ | ✅ | ✅ | matches + left orphans + right orphans |
Syntax: FULL OUTER JOIN and FULL JOIN are the same thing, because OUTER is optional just as in LEFT and RIGHT. In this course we write the full FULL OUTER JOIN, because its rarity justifies being explicit.
And a property that sets it apart from the other two outer joins: the FULL OUTER JOIN is commutative. A FULL OUTER JOIN B and B FULL OUTER JOIN A return the same set of rows, because both sides get the same treatment.
| Property | INNER |
LEFT |
RIGHT |
FULL OUTER |
|---|---|---|---|---|
| Commutative | ✅ | ❌ | ❌ | ✅ |
FULL OUTER JOIN over GreenStore: why orphans only appear on one side
FULL OUTER JOIN over GreenStore: why orphans only appear on one sideLet's try the most obvious case: catalogue against sales.
SELECT p.id AS product_id,
p.name AS product,
ol.id AS line_id,
ol.quantity
FROM products AS p
FULL OUTER JOIN order_lines AS ol ON ol.product_id = p.id
ORDER BY p.id, ol.id;50 rows. Exactly the same as the LEFT JOIN in 03-03 returned:
| Query | Rows |
|---|---|
products INNER JOIN order_lines |
47 |
products LEFT JOIN order_lines |
50 |
products FULL OUTER JOIN order_lines |
50 |
products RIGHT JOIN order_lines |
47 |
The FULL OUTER JOIN hasn't contributed a single row over the LEFT JOIN. Why?
Because for an orphan to appear on the right side there would have to exist an order line whose product_id corresponded to no product. And that's precisely what the foreign key prevents:
Two constraints act at once:
| Constraint | What it prevents |
|---|---|
REFERENCES products(id) |
Inserting a line with a product_id that doesn't exist in products |
NOT NULL |
Inserting a line with no product_id |
ON DELETE RESTRICT |
Deleting a product that has lines, leaving them orphaned |
flowchart LR
A["Can there be a product<br/>with no sales lines?"] -->|"Yes: 13, 19 and 20"| B["orphans<br/>on the LEFT"]
C["Can there be a line<br/>with no product?"] -->|"No: the FK prevents it"| D["orphans on the RIGHT:<br/>impossible"]
A very useful general rule comes out of that:
Between two tables joined by a
NOT NULLforeign key with declared referential integrity, aFULL OUTER JOINis always equivalent to aLEFT JOINfrom the parent table. Writing it is redundant, and on top of that it pays a cost it doesn't need.
The same happens with customers and orders: orders.customer_id is NOT NULL REFERENCES customers(id), so customers FULL OUTER JOIN orders returns the same 23 rows as the LEFT JOIN.
The only case in GreenStore where a FULL OUTER would contribute something is orders with employees, because employee_id allows NULL:
- Orphans on the left: the 10 web orders with no employee.
- Orphans on the right: the 5 employees with no orders.
- Total: 10 pairings + 10 + 5 = 25 rows.
It's the only FULL OUTER JOIN that makes sense in this schema, and even so it would be clearer to write two separate queries: "orders with no sales rep" and "employees with no orders" answer different business questions, and mixing them in one table with nulls on both sides helps nobody.
- The real case: reconciling two data sources
The FULL OUTER JOIN shines when the two tables are not joined by a foreign key: when they're two independent sources that ought to agree and you have to work out where they don't. Common examples:
| Reconciliation | Source A | Source B | What you're looking for |
|---|---|---|---|
| Catalogue against external sales | Own catalogue | A marketplace file | SKUs that don't exist, products not published |
| Inventory against accounting | Physical warehouse count | Accounting system | Stock discrepancies |
| Payroll against directory | HR system | User directory | Joiners and leavers not propagated |
| Payments against invoices | Bank statement | Invoices issued | Payments with no invoice, invoices with no payment |
In all of them the key is the same: neither of the two sources is the authority. Each can have rows the other lacks, and the report's goal is precisely to find them.
The scenario: marketplace sales
GreenStore has started selling on an external marketplace too, which every month sends a CSV file with the units sold per SKU. We load that file into a temporary table:
Warning:
marketplace_salesisn't part of the GreenStore schema. It's an auxiliary table created only for this example. Don't use it in the rest of the course's exercises, and drop it when you're done (or disconnect: beingTEMP, it vanishes with the session).
CREATE TEMP TABLE marketplace_sales (
sku INTEGER,
units INTEGER
);
INSERT INTO marketplace_sales (sku, units) VALUES
( 1, 14), ( 2, 9), ( 3, 6), ( 4, 11), ( 5, 22),
( 6, 4), ( 7, 7), ( 8, 3), ( 9, 12), (10, 5),
(11, 8), (12, 6), (14, 15), (15, 2), (16, 10),
(17, 4), (18, 19), (101, 7), (102, 3);19 rows. Notice two things: there's no foreign key at all between sku and products.id —the file comes from another system, we can't impose constraints on it— and two strange SKUs show up, 101 and 102.
The complete reconciliation
SELECT p.id AS product_id,
p.name AS product,
ms.sku,
ms.units
FROM products AS p
FULL OUTER JOIN marketplace_sales AS ms ON ms.sku = p.id
ORDER BY COALESCE(p.id, ms.sku);| product_id | product | sku | units |
|---|---|---|---|
| 1 | Extra virgin olive oil 500 ml | 1 | 14 |
| 2 | Organic brown rice 1 kg | 2 | 9 |
| 3 | Raw orange blossom honey 500 g | 3 | 6 |
| 4 | Spelt pasta 500 g | 4 | 11 |
| 5 | Organic crushed tomato 400 g | 5 | 22 |
| 6 | Aloe vera face cream 50 ml | 6 | 4 |
| 7 | Rosemary solid shampoo 80 g | 7 | 7 |
| 8 | Almond body oil 200 ml | 8 | 3 |
| 9 | Calendula lip balm 15 ml | 9 | 12 |
| 10 | Concentrated eco laundry detergent 1 L | 10 | 5 |
| 11 | Loofah scrubber (pack of 3) | 11 | 8 |
| 12 | Reusable cotton bags (pack of 5) | 12 | 6 |
| 13 | Soy wax candles (pack of 2) | (null) | (null) |
| 14 | Organic chamomile tea 20 bags | 14 | 15 |
| 15 | Ceremonial matcha green tea 30 g | 15 | 2 |
| 16 | Ginger kombucha 750 ml | 16 | 10 |
| 17 | Cold-pressed orange juice 1 L | 17 | 4 |
| 18 | Bamboo toothbrush | 18 | 19 |
| 19 | Natural stick deodorant 50 g | (null) | (null) |
| 20 | Spirulina capsules 120 units | (null) | (null) |
| (null) | (null) | 101 | 7 |
| (null) | (null) | 102 | 3 |
22 rows = 17 pairings + 3 products with no marketplace sales + 2 unknown SKUs.
Now there really are orphans on both sides, and each one tells a different story:
- Products 13, 19 and 20 haven't sold on the marketplace. They coincide with the ones that don't sell in our own shop either, which reinforces the diagnosis: no stock, discontinued, and a commercial problem.
- SKUs 101 and 102 have generated sales on the marketplace but don't exist in the catalogue. That's a serious operational alert: it could be an old reference under a different numbering, a mapping error between systems, or sales nobody is attributing to any product.
A writing detail: the ORDER BY COALESCE(p.id, ms.sku) sorts by the identifier "wherever it comes from". If you sorted only by p.id, the two marketplace rows would have NULL in that column and would go to the end (or the beginning, depending on NULLS FIRST/LAST, as you saw in 02-05). COALESCE returns the first non-null value in the list, and it's studied in depth in 06-04.
- The "only what doesn't match on either side" pattern
The complete reconciliation is fine for reviewing, but what gets sent to operations is just the list of discrepancies. It's the equivalent of 03-03's anti-join, now on both counts:
SELECT p.id AS product_id,
p.name AS product,
ms.sku,
ms.units
FROM products AS p
FULL OUTER JOIN marketplace_sales AS ms ON ms.sku = p.id
WHERE p.id IS NULL
OR ms.sku IS NULL
ORDER BY COALESCE(p.id, ms.sku);| product_id | product | sku | units |
|---|---|---|---|
| 13 | Soy wax candles (pack of 2) | (null) | (null) |
| 19 | Natural stick deodorant 50 g | (null) | (null) |
| 20 | Spirulina capsules 120 units | (null) | (null) |
| (null) | (null) | 101 | 7 |
| (null) | (null) | 102 | 3 |
5 rows: the three catalogue discrepancies and the two from the file. This is a genuine reconciliation report, and in a real system it would land in your inbox every morning.
In set terms, this pattern returns the symmetric difference:
flowchart LR
subgraph R[" "]
direction LR
A(("catalogue only<br/>✅"))
I(("they match<br/>❌ excluded by the WHERE"))
B(("marketplace only<br/>✅"))
end
style A fill:#d9f0d9,stroke:#2b7a2b,stroke-width:3px
style I fill:#f8f8f8,stroke:#bbb,stroke-dasharray: 4 4
style B fill:#d9f0d9,stroke:#2b7a2b,stroke-width:3px
Two important writing details:
| Detail | Why |
|---|---|
The operator is OR, not AND |
With AND you'd be asking for rows where both keys are missing at once, which is impossible: every row of the result comes from at least one side. AND always returns 0 rows |
| You compare each side's keys | p.id and ms.sku. Just as in the simple anti-join, you have to pick columns that can't legitimately be NULL in their source tables |
And the three variants of the filter, depending on what you want:
WHERE |
Returns | Rows here |
|---|---|---|
| (none) | The complete reconciliation | 22 |
p.id IS NULL OR ms.sku IS NULL |
Discrepancies only (symmetric difference) | 5 |
ms.sku IS NULL |
Only what's in the catalogue and not in the file | 3 |
p.id IS NULL |
Only what's in the file and not in the catalogue | 2 |
- Emulation on engines that don't support it
MySQL and MariaDB don't implement FULL OUTER JOIN. The classic emulation consists of combining a LEFT JOIN and a RIGHT JOIN with UNION:
-- Emulating FULL OUTER JOIN in MySQL
SELECT p.id AS product_id, p.name AS product, ms.sku, ms.units
FROM products AS p
LEFT JOIN marketplace_sales AS ms ON ms.sku = p.id
UNION
SELECT p.id, p.name, ms.sku, ms.units
FROM products AS p
RIGHT JOIN marketplace_sales AS ms ON ms.sku = p.id;The logic is straightforward:
flowchart TD
A["LEFT JOIN<br/>matches + left orphans<br/>= 20 rows"] --> C["UNION<br/>stacks and removes duplicates"]
B["RIGHT JOIN<br/>matches + right orphans<br/>= 19 rows"] --> C
C --> D["22 rows<br/>= FULL OUTER JOIN"]
The 17 matching rows appear in both queries. That's why you have to use UNION and not UNION ALL: UNION removes duplicates and leaves 20 + 19 − 17 = 22 rows. With UNION ALL you'd get 39 rows, with the 17 matches repeated twice.
UNION, UNION ALL and their rules are the subject of lesson 03-07; here it's enough to know that the emulation exists and why it needs to remove duplicates.
Dialect note: in MySQL the emulation has an unpleasant twist. Since
UNIONcompares whole rows, two rows differing in any column are considered distinct; if your data set legitimately contained repeated rows,UNIONwould collapse them and you'd lose information. The robust alternative is a completeLEFT JOINUNION ALLed with only the right-hand orphans (RIGHT JOIN ... WHERE p.id IS NULL), which generates no overlap and needs no deduplication.
- Support by engine, and cost
| Engine | FULL OUTER JOIN |
Note |
|---|---|---|
| PostgreSQL | ✅ Yes | Since very old versions, no restrictions |
| MySQL / MariaDB | ❌ No | You have to emulate it with a UNION of a LEFT and a RIGHT |
| SQLite | ✅ Yes, since 3.39 (June 2022) | Same as RIGHT JOIN |
| SQL Server | ✅ Yes | No restrictions |
| Oracle | ✅ Yes | The old (+) syntax can't express a full outer |
Why it's rarely used
Four reasons, in order of importance:
- Referential integrity makes it unnecessary within a single schema. That's section 2's argument, and it explains most cases.
- What you want is almost always one of the two halves. "Products with no sales" or "sales with no product" are specific questions that a
LEFT JOINwith an anti-join answers better, and with a more readable result. - The result is awkward to read. A table with nulls on both sides forces you into
COALESCEfor sorting, for grouping and for presenting. Every key column exists twice over. - It's the most expensive
JOIN. The engine has to traverse both relations completely and mark the unmatched rows on both sides: it can't stop early or discard soon. In PostgreSQL it's only implemented with a hash join or a merge join; there's nonested loopplan for aFULL OUTER JOIN, which sometimes forces both sides to be materialised and sorted. With large tables you notice it (module 8).
Practical rule: before writing a
FULL OUTER JOIN, ask yourself whether you genuinely need the orphans from both sides in the same result. Nine times out of ten the answer is no, and aLEFT JOINis clearer and faster. The tenth time —a real reconciliation between two systems— is exactly what it exists for.
Common Mistakes and Tips
- Using
FULL OUTER JOINbetween two tables joined by aNOT NULLFK. It contributes no row over theLEFT JOINand costs more. Referential integrity already guarantees there are no orphans on the child side. - Writing
ANDinstead ofORin the discrepancy filter.WHERE p.id IS NULL AND ms.sku IS NULLalways returns 0 rows: no row of the result can be missing from both sides at once. - Sorting by just one of the two keys. The orphan rows from the other side have that column at
NULLand go to the far end of the listing. UseORDER BY COALESCE(a.key, b.key). - Forgetting that the key columns are duplicated. In the reconciliation you have
p.idandms.sku. To present a single identifier you needCOALESCE(p.id, ms.sku). - Emulating it with
UNION ALLin MySQL. It duplicates every matching row: 39 instead of 22 in this lesson's example. - Assuming it exists in MySQL. It doesn't, and it isn't planned. If your SQL has to be portable, avoid it.
- Confusing "no partner" with "empty value". Just as in 03-03: always check against a column that can't legitimately be
NULLin its source table. - Tip: start with the
LEFT JOINand check whether you need more. Run theLEFT, count rows, run theFULL OUTERand compare. If the number doesn't change, theFULL OUTERis redundant. - Tip: in a reconciliation, add a column saying where each row comes from. With
CASE(module 6) you can label each row as "catalogue only", "file only" or "matches". The report becomes self-explanatory. - Tip: keep the discrepancy pattern.
FULL OUTER JOIN ... WHERE a.key IS NULL OR b.key IS NULLis a template you'll reuse every time two systems have to reconcile.
Exercises
Exercise 1
Run these two queries and compare the number of rows:
SELECT c.id, c.name, o.id AS order_id
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.id;
SELECT c.id, c.name, o.id AS order_id
FROM customers AS c
FULL OUTER JOIN orders AS o ON o.customer_id = c.id;- How many rows does each one return?
- Explain the result in terms of the constraints declared on
orders.customer_id. - What would have to change in the schema for the two queries to give different results?
Exercise 2
Over the temporary table marketplace_sales from section 3, write two separate queries:
- The catalogue products that don't appear in the marketplace file, with their name, price and stock.
- The SKUs in the file that don't exist in the catalogue, with the units sold.
Write the first with a LEFT JOIN and the second with a RIGHT JOIN, and then reason it out: why are two separate queries more useful here than section 4's FULL OUTER JOIN?
Exercise 3
Your company is migrating a reconciliation query written in PostgreSQL over to MySQL:
SELECT p.id AS product_id, p.name AS product, ms.sku, ms.units
FROM products AS p
FULL OUTER JOIN marketplace_sales AS ms ON ms.sku = p.id
WHERE p.id IS NULL OR ms.sku IS NULL;- Rewrite it for MySQL using
UNION. - Could you use
UNION ALLin this particular rewrite? Reason it out by looking at what each half returns.
Solutions
Solution 1
1. Both return 23 rows.
2. The column is declared like this:
The three parts act together:
REFERENCES customers(id)prevents inserting an order with a non-existentcustomer_id.NOT NULLprevents inserting an order with no customer.ON DELETE RESTRICTprevents deleting a customer who has orders, which is the other way of generating orphans.
Conclusion: no orphan order can exist, so the right side contributes nothing and the FULL OUTER JOIN degenerates into the LEFT JOIN. The 23 rows are the same ones: 20 orders + 3 customers with no orders.
3. It would be enough for customer_id to allow NULL —for example, to record guest orders with no account. Those orders would match no customer and would appear as orphans on the right, visible only with a FULL OUTER JOIN (or with a RIGHT JOIN). It's exactly the situation of orders.employee_id, which does allow nulls: orders FULL OUTER JOIN employees returns 25 rows against the LEFT JOIN's 20.
Solution 2
1. Catalogue products that aren't in the file:
SELECT p.id,
p.name AS product,
p.price,
p.stock
FROM products AS p
LEFT JOIN marketplace_sales AS ms ON ms.sku = p.id
WHERE ms.sku IS NULL
ORDER BY p.id;| id | product | price | stock |
|---|---|---|---|
| 13 | Soy wax candles (pack of 2) | 13.75 | 0 |
| 19 | Natural stick deodorant 50 g | 7.80 | 75 |
| 20 | Spirulina capsules 120 units | 16.40 | 55 |
2. SKUs in the file that don't exist in the catalogue:
SELECT ms.sku,
ms.units
FROM products AS p
RIGHT JOIN marketplace_sales AS ms ON ms.sku = p.id
WHERE p.id IS NULL
ORDER BY ms.sku;| sku | units |
|---|---|
| 101 | 7 |
| 102 | 3 |
Why two separate queries are more useful here:
| Reason | Explanation |
|---|---|
| Different columns | A product with no sales is interesting with its price and stock; an unknown SKU is interesting with the units sold. In a single table you'd have to return the union of both sets of columns, with half of them NULL on every row |
| Different audiences | The first list goes to marketing; the second, to the integrations team. They're two incidents with two owners |
| Different urgency | A product with no sales is a commercial observation; a sold SKU that doesn't exist in the catalogue is a data error to fix today |
| Readability | Each query has a single meaning and explains itself. The FULL OUTER forces you to read the nulls to work out what kind of row each one is |
The FULL OUTER JOIN is still useful for the overall picture —section 3's 22-row table, for reviewing at a glance— but the operational work is better handled with two targeted queries.
Solution 3
1. Rewrite for MySQL:
-- Left half: catalogue products with no SKU in the file
SELECT p.id AS product_id, p.name AS product, ms.sku, ms.units
FROM products AS p
LEFT JOIN marketplace_sales AS ms ON ms.sku = p.id
WHERE ms.sku IS NULL
UNION
-- Right half: file SKUs with no product in the catalogue
SELECT p.id, p.name, ms.sku, ms.units
FROM products AS p
RIGHT JOIN marketplace_sales AS ms ON ms.sku = p.id
WHERE p.id IS NULL;It returns the same 5 rows as the PostgreSQL version.
2. Can you use UNION ALL? Yes, and it's preferable.
The reasoning is what matters: UNION (without ALL) is needed when the two halves can produce the same rows, and the duplicates have to be removed. That's the case with section 5's general emulation, where both JOINs return the 17 matching rows.
That doesn't happen here. Each half carries its own WHERE restricting it to the orphans of its side:
| Half | Filter | Returns | Overlap |
|---|---|---|---|
| Left | ms.sku IS NULL |
3 rows: products with no SKU | — |
| Right | p.id IS NULL |
2 rows: SKUs with no product | none |
No row can satisfy both conditions at once, so the sets are disjoint and there's nothing to deduplicate. UNION ALL is faster because it saves the sorting or hash-table step UNION needs to detect duplicates. It's a perfect example of the rule you'll see in 03-07: use UNION ALL unless you have a specific reason to remove duplicates.
Conclusion
You've closed the outer-join family:
- The
FULL OUTER JOINkeeps the orphan rows from both sides: it's aLEFTand aRIGHTat once. It's the only commutative outer join. - Over GreenStore it almost never contributes anything, and you can explain why: referential integrity (
REFERENCES+NOT NULL+ON DELETE RESTRICT) prevents orphans on the child side from existing.products FULL OUTER JOIN order_linesreturns the same 50 rows as theLEFT JOIN. - Its real ground is the reconciliation of two independent sources, where neither has authority over the other: a catalogue against a marketplace file, an inventory against accounting, a bank statement against invoices.
- You've built that complete reconciliation (22 rows) and the discrepancy report with the pattern
WHERE a.key IS NULL OR b.key IS NULL(5 rows): three products the marketplace doesn't sell and two SKUs that don't exist in the catalogue. Remember: the operator isOR, neverAND. - You know how to emulate it with a
UNIONof aLEFT JOINand aRIGHT JOINfor MySQL, and why that emulation needs to remove duplicates unless each half is restricted to its own orphans. - And you know its cost: it admits no nested loop plan, it forces both relations to be traversed in full and it produces a result that needs
COALESCEfor nearly everything. Use it when you genuinely need both sides.
In the next lesson, SELF JOIN and CROSS JOIN, we'll look at the two "odd" JOINs that confuse beginners the most. The SELF JOIN will finally let you walk GreenStore's two reflexive relationships: the employees hierarchy with manager_id —where you'll need a LEFT JOIN again so as not to lose Rosa, the manager with no manager— and the customers referral network. The CROSS JOIN, which so far has only shown up as an accident, will become a deliberate tool for generating complete combinations.
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
