Skip to content

JOINs

Chapter 5 12 code blocks 4274 words

Chapter 1 explained why data is split across tables: each fact is stored exactly once, so it cannot disagree with itself. JOINs are the other half of that bargain — they put the pieces back together for the duration of one query.

This is the chapter where students either "get" SQL or quietly stop understanding it, so go slowly and always draw the two tables before writing the query.

Why JOINs exist

Look at what enrollments actually stores:

SELECT * FROM enrollments LIMIT 3;

enrollment_idstudent_idcourse_idteacher_idenrolled_atgradestatus

11142025-09-0592completed 21222025-09-0588completed 31512026-02-03NULLactive

Nobody can read this. It says "1 studied 1 with 4" — three numbers and no meaning. A human needs "Aziza studied Python Basics with Sardor Yoqubov". Turning the first table into the second sentence is the entire job of a JOIN.

📌 The mental model

A JOIN takes each row of the left table, looks for rows in the right table where the ON condition is true, and glues the matching rows side by side into one wider row. What differs between JOIN types is only one thing: what happens to a row that finds no match.

INNER JOIN

INNER JOIN keeps only rows that matched on both sides. It is the default — plain JOIN means INNER JOIN.

sql9 lines
SELECT
    s.first_name,
    s.last_name,
    e.grade,
    e.status
FROM enrollments e
INNER JOIN students s ON e.student_id = s.student_id
ORDER BY e.enrollment_id
LIMIT 5;
first_namelast_namegradestatus
RustamNazarov88completed
SardorKarimovNaNactive
GulnoraYusupova75completed
SardorKarimovNaNactive
KamolaUmarovaNaNactive

5 rows

1 ms

💡 How to write a JOIN, every time
  1. Which tables hold the columns I need?
  2. What column do they share? (Look at the foreign keys.)
  3. Which table is the "many" side? Start FROM there.
  4. Must unmatched rows survive? If yes → LEFT JOIN. If no → INNER JOIN.

ON, USING, and the old comma syntax

python12 lines
-- 1. ON — always works, and the only option when column names differ
SELECT s.first_name, e.grade
FROM students s JOIN enrollments e ON s.student_id = e.student_id;

-- 2. USING — shorter, but only when BOTH columns have the same name
SELECT s.first_name, e.grade
FROM students s JOIN enrollments e USING (student_id);

-- 3. The old comma syntax — an implicit inner join. Do not teach this.
SELECT s.first_name, e.grade
FROM students s, enrollments e
WHERE s.student_id = e.student_id;

Result 1

first_namegrade
Rustam88
SardorNaN
Gulnora75
SardorNaN
KamolaNaN
Ulug'bek83
MalikaNaN
Sevara77
Dilnoza63
SevaraNaN
Otabek67
Kamola62
Gulnora77
Zulfiya79
Zulfiya80

… first 15 of 2500 rows

2500 rows

Result 2

first_namegrade
Rustam88
SardorNaN
Gulnora75
SardorNaN
KamolaNaN
Ulug'bek83
MalikaNaN
Sevara77
Dilnoza63
SevaraNaN
Otabek67
Kamola62
Gulnora77
Zulfiya79
Zulfiya80

… first 15 of 2500 rows

2500 rows

Result 3

first_namegrade
Rustam88
SardorNaN
Gulnora75
SardorNaN
KamolaNaN
Ulug'bek83
MalikaNaN
Sevara77
Dilnoza63
SevaraNaN
Otabek67
Kamola62
Gulnora77
Zulfiya79
Zulfiya80

… first 15 of 2500 rows

2500 rows

4 ms

:::gotcha ⚠️ Why the comma syntax is banned in modern code Forget the WHERE line and you do not get an error — you get a CROSS JOIN: every student paired with every enrollment, 10 × 20 = 200 rows of nonsense. With explicit JOIN ... ON, the join condition sits next to the join and cannot be lost. There is also no way to write an outer join with commas. Use JOIN ... ON. :::

⚠️ `NATURAL JOIN` — never use it

It joins on every column that happens to share a name in both tables. Add a created_at column to two tables next year and every NATURAL JOIN in the codebase silently changes meaning. Always state the condition explicitly.

LEFT JOIN

LEFT JOIN keeps every row of the left table, matched or not. Where there was no match, the right table's columns come back as NULL.

This is exactly the fix for the missing course in Chapter 4:

sql8 lines
SELECT
    c.title,
    COUNT(e.enrollment_id) AS enrollments,
    ROUND(AVG(e.grade), 1) AS avg_grade
FROM courses c
LEFT JOIN enrollments e ON c.course_id = e.course_id
GROUP BY c.course_id, c.title
ORDER BY enrollments DESC;
titleenrollmentsavg_grade
Linear Algebra20068.3
Python Basics19379.2
Financial Analysis18974.7
Probability18865.6
Java Basics18773.9
SQL for Analytics18674.3
Graphic Design17973.5
Statistics 10117463.6
Web Development17475.2
Project Management17075.1
Data Visualization16774.2
Machine Learning16769.4
Excel for Business16579.9
UX Design16176.1
Deep Learning0NaN

15 rows

1 ms

Deep Learning0NULL

Deep Learning finally appears, with 0. That single row is usually the most valuable line in the whole report — the course nobody signed up for is exactly what management needs to see.

⚠️ `COUNT(*)` would have printed 1, not 0

The LEFT JOIN produces one row for Deep Learning, with all the enrollment columns NULL. COUNT(*) counts rows, so it returns 1 — a course with zero students reported as having one. COUNT(e.enrollment_id) counts non-NULL values, so it correctly returns 0.

Rule: after a LEFT JOIN, always count a column from the right-hand table, never COUNT(*). This is a genuine, frequent production bug and a favourite interview question.

The same shape works for the NULL teacher:

sql6 lines
SELECT
    e.enrollment_id,
    COALESCE(t.full_name, '(not assigned)') AS teacher
FROM enrollments e
LEFT JOIN teachers t ON e.teacher_id = t.teacher_id
WHERE e.enrollment_id IN (18, 19, 20);
enrollment_idteacher
18Zulfiya Saidova
19Malika Umarova
20Malika Umarova

3 rows

0 ms

RIGHT and FULL OUTER JOIN

RIGHT JOIN is the mirror image: it keeps every row of the right table. FULL OUTER JOIN keeps every row of both, filling NULLs on whichever side is missing.

INNER JOIN only rows matching in both LEFT JOIN all left rows + matches RIGHT JOIN all right rows + matches FULL OUTER JOIN everything from both sides LEFT ANTI-JOIN LEFT JOIN + WHERE right IS NULL CROSS JOIN every row × every row m × n rows — no condition
⚠️ SQLite has no RIGHT or FULL JOIN before version 3.39

It does not matter, because every RIGHT JOIN is a LEFT JOIN with the tables swapped:

A RIGHT JOIN B ON ... ≡ B LEFT JOIN A ON ... In practice almost nobody writes RIGHT JOIN: reading a query is much easier when every join extends the same driving table leftwards. Teach LEFT JOIN, mention RIGHT JOIN exists, and move on.

FULL OUTER JOIN is rare in analytics but genuinely useful for reconciliation — comparing two systems that should contain the same records and finding what is missing on either side. Where it is unsupported, emulate it with two LEFT JOINs and a UNION (Chapter 9).

Anti-join: rows with no match

"Which X has no Y?" is one of the most common analytics questions there is: customers who never ordered, products never sold, students not enrolled. The pattern is always the same — LEFT JOIN, then keep the rows where the right side came back NULL.

sql4 lines
SELECT c.course_id, c.title
FROM courses c
LEFT JOIN enrollments e ON c.course_id = e.course_id
WHERE e.enrollment_id IS NULL;
course_idtitle
12Deep Learning

1 row

1 ms

python12 lines
-- (a) LEFT JOIN + IS NULL — usually the fastest
SELECT c.title FROM courses c
LEFT JOIN enrollments e ON c.course_id = e.course_id
WHERE e.enrollment_id IS NULL;

-- (b) NOT EXISTS — clearest intent, NULL-safe
SELECT c.title FROM courses c
WHERE NOT EXISTS (SELECT 1 FROM enrollments e WHERE e.course_id = c.course_id);

-- (c) NOT IN — only safe because enrollments.course_id is NOT NULL. Fragile.
SELECT c.title FROM courses c
WHERE c.course_id NOT IN (SELECT course_id FROM enrollments);

Result 1

title
Deep Learning

1 row

Result 2

title
Deep Learning

1 row

Result 3

title
Deep Learning

1 row

2 ms

:::tip 💡 Pick the column you test carefully In pattern (a), test a column of the right table that can never be NULL in real data — its primary key is the safe choice. If you test a nullable column such as e.grade, you will also catch enrollments that exist but are ungraded, and the answer will be wrong. :::

Joining three or more tables

JOINs chain: the result of one join is a table, which is then joined to the next. Start from the junction table (enrollments) and hang the lookup tables off it.

sql12 lines
SELECT
    s.first_name || ' ' || s.last_name    AS student,
    c.title                               AS course,
    COALESCE(t.full_name, '(none)')       AS teacher,
    e.grade,
    e.status
FROM enrollments e
JOIN      students s ON e.student_id = s.student_id
JOIN      courses  c ON e.course_id  = c.course_id
LEFT JOIN teachers t ON e.teacher_id = t.teacher_id
ORDER BY e.enrollment_id
LIMIT 6;
studentcourseteachergradestatus
Rustam NazarovWeb DevelopmentDilshod Karimov88completed
Sardor KarimovMachine LearningJasur ToshevNaNactive
Gulnora YusupovaStatistics 101Kamola Ergasheva75completed
Sardor KarimovSQL for AnalyticsNodira AlimovaNaNactive
Kamola UmarovaData Visualization(none)NaNactive
Ulug'bek QodirovExcel for BusinessSevara Aliyeva83completed

6 rows

0 ms

⚠️ A LEFT JOIN followed by an INNER JOIN cancels the LEFT

Order matters. If you write FROM courses c LEFT JOIN enrollments e ... JOIN students s ON e.student_id = s.student_id, the Deep Learning row (whose e.student_id is NULL) matches no student and the inner join deletes it. All the work of the LEFT JOIN is undone. Once you go LEFT, stay LEFT for every table further down that chain.

CROSS JOIN and SELF JOIN

CROSS JOIN

Every row of A with every row of B — no condition. The result has m × n rows.

SELECT COUNT(*) FROM students CROSS JOIN courses; -- 10 × 6 = 60 It is almost always an accident (a forgotten ON), but it has one genuine use: building a complete grid to report zeros against. "Every course, every month, even the empty combinations" is impossible with GROUP BY alone — you must generate the grid first, then LEFT JOIN the real data onto it.

SELF JOIN

A table joined to itself, using two different aliases so the engine can tell the copies apart. Use it to compare rows within one table.

python9 lines
-- Pairs of students who live in the same city
-- (a.student_id < b.student_id prevents self-pairs AND mirror duplicates)
SELECT
    a.first_name AS student_a,
    b.first_name AS student_b,
    a.city
FROM students a
JOIN students b ON a.city = b.city AND a.student_id < b.student_id
ORDER BY a.city;
student_astudent_bcity
DilnozaAkmalAndijan
DilnozaAkmalAndijan
DilnozaAzizaAndijan
DilnozaAzizaAndijan
DilnozaDostonAndijan
DilnozaDostonAndijan
DilnozaGulnoraAndijan
DilnozaJasurAndijan
DilnozaJasurAndijan
DilnozaKamolaAndijan
DilnozaKamolaAndijan
DilnozaKamolaAndijan
DilnozaMalikaAndijan
DilnozaOtabekAndijan
DilnozaOtabekAndijan

… first 15 of 6550 rows

6550 rows

4 ms

student_astudent_bcity

BekzodZarinaSamarkand AzizaJasurTashkent AzizaOtabekTashkent JasurOtabekTashkent

Sevara is absent because her city is NULL, and NULL = NULL is UNKNOWN — the NULL rule from Chapter 1 applies inside ON exactly as it does inside WHERE.

💡 Why `<` and not `<>`

With a.student_id <> b.student_id you get both (Aziza, Jasur) and (Jasur, Aziza) — every pair twice. < keeps exactly one direction. This trick appears in every self-join task; interviewers look for it.

The other classic self-join is the employee/manager hierarchy: FROM employees e JOIN employees m ON e.manager_id = m.employee_id.

Fan-out: the silent row-multiplication bug

This is the most expensive JOIN mistake, because the query runs perfectly and returns a plausible number that is simply wrong.

sql7 lines
SELECT COUNT(*) FROM students;                                  -- 10

SELECT COUNT(*) FROM students s
JOIN enrollments e ON s.student_id = e.student_id;              -- 20  ← !!

SELECT COUNT(DISTINCT s.student_id) FROM students s
JOIN enrollments e ON s.student_id = e.student_id;              -- 10

Result 1

COUNT(*)
300

1 row

Result 2

COUNT(*)
2 500

1 row

Result 3

COUNT(DISTINCT s.student_id)
300

1 row

1 ms

Joining to the "many" side duplicated every student row — once per enrollment. Counting is now counting enrollments, not students. With money, it gets worse:

python8 lines
-- What is our total teacher salary bill?
SELECT SUM(salary) FROM teachers;
-- 36,500,000  — correct

-- Same question, but someone joined enrollments to filter by course
SELECT SUM(t.salary) FROM teachers t
JOIN enrollments e ON t.teacher_id = e.teacher_id;
-- 175,600,000  — nearly 5× too high, and no error anywhere

Result 1

SUM(salary)
117 300 000

1 row

Result 2

SUM(t.salary)
23 933 000 000

1 row

1 ms

Nodira's salary was counted 6 times (she appears in 6 enrollments), Rustam's 5 times, and so on. The report looks professional and is off by 139 million.

⚠️ How to spot and fix fan-out
  • Spot it: run SELECT COUNT(*) before and after adding a JOIN. If the number grew, the rows on the left were duplicated — every aggregate you compute from that side is now inflated.
  • Fix it properly: aggregate before joining. Collapse the many-side to one row per key in a CTE, then join that (Chapter 6): WITH per_teacher AS ( SELECT teacher_id, COUNT(*) AS lessons FROM enrollments GROUP BY teacher_id ) SELECT t.full_name, t.salary, p.lessons FROM teachers t LEFT JOIN per_teacher p USING (teacher_id);
  • Do not "fix" it with DISTINCT. SUM(DISTINCT salary) would collapse two teachers who happen to earn the same amount into one. DISTINCT hides the symptom and creates a new bug.

ON vs WHERE in an outer join

In an INNER JOIN it makes no difference where you put a condition. In a LEFT JOIN it changes the answer completely, and this is a top-tier interview question.

python16 lines
-- (A) condition in ON: filters the RIGHT table BEFORE joining.
--     Every course is still kept.
SELECT c.title, COUNT(e.enrollment_id) AS completed
FROM courses c
LEFT JOIN enrollments e
       ON c.course_id = e.course_id AND e.status = 'completed'
GROUP BY c.course_id, c.title;

-- (B) condition in WHERE: filters the RESULT AFTER joining.
--     Courses with no completed enrollment are deleted — the LEFT JOIN
--     silently became an INNER JOIN.
SELECT c.title, COUNT(e.enrollment_id) AS completed
FROM courses c
LEFT JOIN enrollments e ON c.course_id = e.course_id
WHERE e.status = 'completed'
GROUP BY c.course_id, c.title;

Result 1

titlecompleted
Python Basics130
SQL for Analytics134
Statistics 10181
Data Visualization111
Machine Learning110
Web Development114
Excel for Business120
Project Management118
UX Design112
Graphic Design139
Linear Algebra116
Deep Learning0
Java Basics123
Financial Analysis125
Probability101

15 rows

Result 2

titlecompleted
Python Basics130
SQL for Analytics134
Statistics 10181
Data Visualization111
Machine Learning110
Web Development114
Excel for Business120
Project Management118
UX Design112
Graphic Design139
Linear Algebra116
Java Basics123
Financial Analysis125
Probability101

14 rows

2 ms

Deep Learning0row disappears

:::info 📌 The rule In a LEFT JOIN: conditions on the right table go in ON. Conditions on the left table go in WHERE. Putting a right-table condition in WHERE turns your outer join back into an inner join — except for IS NULL, which is the anti-join pattern and is deliberate. :::

Practice set

python46 lines
-- 1. Every student with the number of courses they take (including zero)
SELECT s.first_name, COUNT(e.enrollment_id) AS courses
FROM students s LEFT JOIN enrollments e ON s.student_id = e.student_id
GROUP BY s.student_id, s.first_name ORDER BY courses DESC;

-- 2. Teachers and how many enrollments they handle
SELECT t.full_name, t.subject, COUNT(e.enrollment_id) AS lessons
FROM teachers t LEFT JOIN enrollments e ON t.teacher_id = e.teacher_id
GROUP BY t.teacher_id, t.full_name, t.subject ORDER BY lessons DESC;

-- 3. Which students take 'SQL for Analytics'?
SELECT s.first_name, s.last_name, e.grade
FROM enrollments e
JOIN students s ON e.student_id = s.student_id
JOIN courses  c ON e.course_id  = c.course_id
WHERE c.title = 'SQL for Analytics' ORDER BY e.grade DESC;

-- 4. Courses nobody enrolled in (anti-join)
SELECT c.title FROM courses c
LEFT JOIN enrollments e ON c.course_id = e.course_id
WHERE e.enrollment_id IS NULL;

-- 5. Revenue per course category (price counted once per enrollment)
SELECT c.category, COUNT(*) AS enrollments, SUM(c.price) AS revenue
FROM enrollments e JOIN courses c ON e.course_id = c.course_id
GROUP BY c.category ORDER BY revenue DESC;

-- 6. Average grade per city (students who have grades)
SELECT COALESCE(s.city,'(unknown)') AS city,
       COUNT(e.grade) AS graded, ROUND(AVG(e.grade),1) AS avg_grade
FROM students s JOIN enrollments e ON s.student_id = e.student_id
GROUP BY s.city ORDER BY avg_grade DESC;

-- 7. Full readable enrollment report
SELECT s.first_name || ' ' || s.last_name AS student, c.title AS course,
       COALESCE(t.full_name,'(none)') AS teacher, e.grade, e.status
FROM enrollments e
JOIN students s ON e.student_id = s.student_id
JOIN courses  c ON e.course_id  = c.course_id
LEFT JOIN teachers t ON e.teacher_id = t.teacher_id
ORDER BY student, course;

-- 8. Students in the same city (self join, one direction only)
SELECT a.first_name AS a, b.first_name AS b, a.city
FROM students a JOIN students b
  ON a.city = b.city AND a.student_id < b.student_id;

Result 1

first_namecourses
Zulfiya19
Shohrux17
Sardor16
Gulnora16
Otabek15
Shohrux15
Aziz15
Nilufar15
Gulnora15
Shahnoza15
Malika14
Zulfiya14
Akmal14
Kamola14
Kamola14

… first 15 of 300 rows

300 rows

Result 2

full_namesubjectlessons
Kamola ErgashevaStatistics286
Sardor QodirovProgramming276
Sevara AliyevaBusiness273
Dilshod KarimovProgramming260
Nodira AlimovaData Science258
Zulfiya SaidovaStatistics256
Jasur ToshevData Science251
Otabek RahimovBusiness241
Gulnora YusupovaDesign168
Malika UmarovaDesign164
Rustam HalilovDatabases0
Akmal NazarovDatabases0

12 rows

Result 3

first_namelast_namegrade
GulnoraQodirova96
ZulfiyaYusupova95
KamolaTosheva94
Ulug'bekYusupov92
RustamSaidov91
ShahnozaRahimova91
DostonRahimov90
ShohruxUmarov90
ShohruxAlimov90
KamolaErgasheva90
KamolaKarimova90
GulnoraErgasheva89
OtabekSaidov89
OtabekUmarov88
AzizaNazarova88

… first 15 of 186 rows

186 rows

Result 4

title
Deep Learning

1 row

Result 5

categoryenrollmentsrevenue
Business524720 000 000
Math562683 800 000
Programming554646 750 000
Data520541 250 000
Design340306 900 000

5 rows

Result 6

citygradedavg_grade
Samarkand23376.7
Nukus15473.2
Namangan17373.1
Karshi15473.1
Andijan13472.8
Bukhara23872.2
Tashkent49572
Fergana20871.9
(unknown)3771.8

9 rows

Result 7

studentcourseteachergradestatus
Akmal AlimovFinancial AnalysisSevara Aliyeva58failed
Akmal AlimovFinancial AnalysisSevara Aliyeva71completed
Akmal AlimovJava BasicsSardor Qodirov71completed
Akmal AlimovJava BasicsDilshod Karimov85completed
Akmal AlimovLinear AlgebraKamola Ergasheva56failed
Akmal AlimovLinear AlgebraZulfiya Saidova78completed
Akmal AlimovLinear AlgebraKamola Ergasheva57failed
Akmal AlimovProject ManagementSevara AliyevaNaNdropped
Akmal AlimovPython BasicsSardor Qodirov88completed
Akmal AlimovPython BasicsDilshod Karimov62completed
Akmal AlimovSQL for AnalyticsNodira Alimova83completed
Akmal AlimovSQL for AnalyticsNodira Alimova70completed
Akmal AlimovSQL for AnalyticsNodira Alimova64completed
Akmal AlimovStatistics 101Kamola Ergasheva72completed
Akmal AlimovStatistics 101Kamola Ergasheva50failed

… first 15 of 2500 rows

2500 rows

Result 8

abcity
ShahnozaAkmalBukhara
ShahnozaAzizBukhara
ShahnozaAzizBukhara
ShahnozaAzizBukhara
ShahnozaAzizBukhara
ShahnozaAzizaBukhara
ShahnozaBekzodBukhara
ShahnozaBekzodBukhara
ShahnozaDilnozaBukhara
ShahnozaDilnozaBukhara
ShahnozaGulnoraBukhara
ShahnozaGulnoraBukhara
ShahnozaGulnoraBukhara
ShahnozaJasurBukhara
ShahnozaJasurBukhara

… first 15 of 6550 rows

6550 rows

13 ms

Interview questions

Q: INNER JOIN vs LEFT JOIN?

INNER returns only rows that matched on both sides. LEFT returns every row of the left table, with NULLs where the right side had no match. Use LEFT whenever "zero" is a meaningful answer.

Q: After a LEFT JOIN, why does COUNT(*) give the wrong answer?

Because unmatched left rows still produce one output row (with NULLs on the right), and COUNT(*) counts rows. Count a non-nullable column from the right table instead: COUNT(e.enrollment_id).

Q: What is the difference between putting a condition in ON versus WHERE in a LEFT JOIN?

ON filters the right table before joining, so unmatched left rows survive. WHERE filters after joining, so it removes the NULL-filled rows and effectively converts the LEFT JOIN into an INNER JOIN.

Q: How do you find rows in A with no match in B?

A LEFT JOIN B ON ... WHERE B.primary_key IS NULL, or NOT EXISTS. Avoid NOT IN unless the column is guaranteed non-NULL (Chapter 3).

Q: What is a self join and when do you need one?

A table joined to itself under two aliases, to compare rows within the same table — employee/manager hierarchies, pairs sharing an attribute, consecutive events. Add a.id < b.id to avoid self-pairs and mirrored duplicates.

Q: Your SUM doubled after adding a JOIN. What happened?

Fan-out: the join matched multiple rows on the many side, duplicating the left rows and therefore the values being summed. Fix it by aggregating the many side first (a CTE or derived table), then joining — not with DISTINCT.

Q: Does JOIN order affect the result?

For inner joins, no — they are commutative and the optimiser reorders them freely. For outer joins, yes: an INNER JOIN placed after a LEFT JOIN can delete the NULL-extended rows the LEFT JOIN just preserved.

Q: What is a CROSS JOIN good for?

Generating a complete grid of combinations — every product × every month — which you then LEFT JOIN real data onto so the empty combinations report as 0 instead of disappearing.

Summary

  • A JOIN glues matching rows side by side. The only difference between join types is what happens to unmatched rows.
  • INNER JOIN = matches only. LEFT JOIN = all left rows + matches. RIGHT = mirror of LEFT. FULL = both sides. CROSS = every combination.
  • Use explicit JOIN ... ON. Avoid comma joins and NATURAL JOIN.
  • After a LEFT JOIN, count a right-table column, never COUNT(*), or zeros become ones.
  • Anti-join = LEFT JOIN ... WHERE right_pk IS NULL — the answer to every "which X has no Y?" question.
  • ON filters before, WHERE filters after. A right-table condition in WHERE silently converts a LEFT JOIN into an INNER JOIN.
  • Once you go LEFT, stay LEFT — a later INNER JOIN undoes it.
  • Fan-out is the dangerous bug: joining to the many side duplicates rows and inflates every SUM and COUNT. Check row counts before and after; aggregate first, then join.

Next: Chapter 6 — subqueries and CTEs, the tools that let you use the result of one query as the input to another.

Questions about this chapter?

The course, homework review and the other five books are in Uzbek. Questions in English are welcome on Telegram.

Write on Telegram Contents